Compare commits

..

3 Commits

Author SHA1 Message Date
spouliot da2bb46d5a Tighten Prismatic scrape parsing after live smoke test
Validated against live product pages; fixed three edge cases (also present in
the original JS scraper) surfaced by specialty AkzoNobel products:

- Sample image: only accept real product images on the NIC CDN
  (images.nicindustries.com/prismatic/products), preferring full-size over
  thumbnail. Dropped the loose "prismatic|powder|color" fallback that grabbed
  the site logo on products with no image.
- SDS/TDS/app-guide links: require the href to be an actual document (NIC CDN
  or a .pdf) so a generic /documents nav link isn't captured as the SDS.
- Description: also stop at PRODUCT SUPPORT / PRODUCT COLLECTIONS / CUSTOMER
  SERVICE so less page footer is captured (app-side StripBoilerplate cleans the
  rest).

Structural fields (sku, color, price tiers) verified correct on live data.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 12:41:47 -04:00
spouliot 843d1c3c51 Add token-authenticated catalog import API endpoint
POST /PowderCatalog/ImportApi accepts the JSON scrape format in the request
body, authenticated by a shared secret in the X-Import-Token header (matched
constant-time against CatalogImport:Token), with the vendor in X-Vendor-Name.
Runs through the same ImportJsonAsync -> shared upsert as the manual upload, so
the offline PrismaticSync tool can push unattended.

ImportJsonAsync refactored to take a Stream (the form upload now passes
file.OpenReadStream()). Endpoint is AllowAnonymous + IgnoreAntiforgeryToken
(it's token-gated, not cookie-auth) and returns 401 until a token is configured,
so it's inert by default. README updated with the route + token wiring.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 11:35:30 -04:00
spouliot c59d55529f Add PrismaticSync console tool for unattended Prismatic catalog sync
Standalone .NET 8 console app (not part of the main solution) that scrapes the
Prismatic Powders catalog via Playwright and pushes it into the app's catalog
import. Prismatic has no API, so this runs on a workstation (Task Scheduler),
never the deployed server.

- Discovery: incremental newest-first via ?category=created_at (stops once it
  reaches already-known URLs — cheap, finds new colors) and a full all-colors
  crawl for occasional reconcile.
- Scraper: resumable product-page scrape (sku/color/description/price tiers/
  SDS/TDS/app-guide/image), with --refresh-older-than to re-scrape stale
  products and catch price changes. Output matches the app import format so it
  flows through the same shared upsert as the Columbia sync.
- Resilience: brisk randomized base delay, escalating 403 cooldown-and-retry to
  avoid hard bans, periodic rest. All configurable.
- Visibility: streams every product + the inter-product wait to the console
  (colored) and a log file, with an up-front ETA.
- Push: token-authenticated POST to the app import endpoint (skips to manual
  upload when unconfigured).

The app-side token import endpoint is a separate follow-up.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 11:30:47 -04:00
103 changed files with 1717 additions and 39757 deletions
-285
View File
@@ -1,285 +0,0 @@
# Accounting System Audit
> Living record of the accounting/GL audit and its remediation status.
> **Keep this file updated** whenever an accounting finding is opened or closed — the
> original audit was lost once because it was never written down. Don't let that happen again.
Last reconciled against code: **2026-06-19** (branch `dev`, commits through `9532812`).
Verification at that point: `dotnet build` clean (0 errors); `dotnet test tests/PowderCoating.UnitTests`**290 passed, 0 failed**.
---
## How the GL is modeled (context for the findings)
Balances are computed two different ways, and they must agree:
1. **At posting time** — controllers call `AccountBalanceService.DebitAsync/CreditAsync`, which
mutate the denormalized `Account.CurrentBalance`.
2. **At report time**`FinancialReportService` (Trial Balance, Balance Sheet, P&L, AR/AP aging,
statements) and `LedgerService` (per-account ledger + `RecalculateBalances`) recompute balances
from source documents.
`LedgerService` also backs the **Balance Reconciliation report** (the detective control added in
`c2cd19e`), which compares stored `CurrentBalance` vs the ledger-recomputed balance. Any account
whose recompute path is incomplete will (a) be silently corrupted by a "Recalculate Balances" run
and (b) produce misleading output in the reconciliation report.
---
## Resolved findings (this audit batch)
| # | Finding | Fix | Commit | Verified |
|---|---------|-----|--------|----------|
| **#4** | `Sales Tax Payable` (2200) was credited on every invoice but never relieved — the liability grew forever (no remittance flow). | `JournalEntries/SalesTaxPayment` GET+POST: DR 2200 / CR bank, balanced, in a transaction, honors the period lock and validates amount + bank-account tenancy. | `0c921ba` | Posting reviewed `JournalEntriesController:235-296`. Reporting needs no change (posted JE lines already flow through TB/BS/ledger). |
| **#7** | Most report queries relied on the global tenant filter, which is **bypassed for SuperAdmin** → cross-company leakage on P&L / Balance Sheet / Trial Balance / aging / statements. | Explicit `CompanyId == companyId` predicate added to every query in `FinancialReportService`. | `9532812` | Confirmed: every query in `FinancialReportService.cs` carries an explicit `CompanyId` predicate. |
| **#9a** | A cash refund posted **DR AR** (control up) while the customer subledger went down — opposite directions → guaranteed AR drift. | Refund now **reverses the sale**: DR Sales Returns (4960) + DR Sales Tax Payable (tax portion) / CR bank; AR untouched. Split centralized in `Core/Accounting/RefundAllocation.Split`. | `2a82a1d` | Posting (`InvoicesController.IssueRefund`/`CancelRefund`) and both recompute paths (`LedgerService` §5b, `FinancialReportService` TB/BS/P&L) all use `RefundAllocation.Split` — they agree by construction. |
| **#9b** | Store-credit refunds & credit memos posted nothing to the GL on issue (only a `CreditMemo` + `Customer.CreditBalance`) → outstanding store credit invisible on the balance sheet. | New **2350 Customer Credits** liability. Issue: DR 4950 / CR 2350. Apply: DR 2350 / CR AR. Void remainder: DR 2350 / CR 4950. Updated in all 8 posting sites + TB/BS/P&L + ledger §12b. | `9ce3612` | Posting sites confirmed (`CreditMemosController` 180-185/262-270/314-320; `InvoicesController` store-credit path). Reporting `cmContraRevenue`/`cmIssuedNonVoided` math reviewed. |
| — | No detective control to catch denormalized-balance drift. | **Balance Reconciliation report** (`Reports/Reconciliation`): per-account stored vs recomputed, plus AR/AP control-vs-subledger. | `c2cd19e` | Reviewed `FinancialReportService.GetBalanceReconciliationAsync`. NOTE: its usefulness is limited by O2 below. |
### Original numbering gap
The commits reference findings **#4, #7, #9a, #9b** — implying a list of at least #1#9. The original
audit document was never persisted and is unrecoverable. Findings **#1, #2, #3, #5, #6, #8** cannot be
matched to commits and their status is **unknown**. If the original list resurfaces, reconcile it here.
---
## Resolved findings (2026-06-19 remediation)
### O1 — Account 2300 mislabeled "Payroll Liabilities" while used as Customer Deposits — **RESOLVED**
- **Root cause refined:** there is no payroll feature posting to 2300; the deposit GL code has always used
2300 (resolved by number) as the Customer Deposits liability. Migration `AccountingDepositsGL` already
renamed it to "Customer Deposits" for tenants that lacked a 2300, but its `NOT EXISTS` guard **skipped**
pre-existing tenants, leaving them mislabeled. The two seed files still created new tenants with the
wrong name.
- **Fix (chosen approach: rename 2300, move payroll to a new 2400):**
- Seed files now create **2300 = "Customer Deposits" (IsSystem)** and a separate **2400 = "Payroll
Liabilities"** — `SeedDataService.Accounts.cs`, `SeedData.cs`.
- `EnsureSystemAccountsAsync` self-heals: renames any 2300 still named "Payroll Liabilities" → "Customer
Deposits" (preserving user renames) and ensures 2400 exists.
- Migration `20260619233108_RenameDepositsAccountAddPayroll` does the same for existing tenants (rename
only where Name is still the default; insert 2400 where missing). Down is best-effort (soft-deletes
only empty 2400s; does not re-introduce the mislabel).
- Account **number 2300 is unchanged**, so the deposit posting code (`DepositsController`,
`InvoicesController`) needed no changes — its lookups are by `"2300"`.
### O2 — `LedgerService` never recomputed 4950 Sales Discounts → recalc corrupted it — **RESOLVED**
- **Fix:** added a 4950 section to both `LedgerService.GetAccountLedgerAsync` and `ComputePriorBalanceAsync`
that reproduces the *actual postings* so a balance recalc matches the stored balance:
invoice discounts (DR at invoice date) + credit-memo issuance (DR full amount at issue) the unapplied
remainder of voided memos (CR at void). This mirrors what `AccountBalanceService` posts at
`InvoicesController:849/1108/1629` and `CreditMemosController`, so the Balance Reconciliation report no
longer shows false drift on 4950.
- **Note / micro-discrepancy:** the ledger uses `memo.AmountApplied` for the voided remainder (matching the
true posting), whereas `FinancialReportService` TB/BS derive the voided portion from applications against
non-voided invoices. These differ only in the rare case of a voided memo applied to a since-voided
invoice. Left as-is (report-side nuance, not O2's target); documented here so it isn't mistaken for a bug.
- Regression test: `LedgerServiceTests.GetAccountLedgerAsync_SalesDiscounts4950_IncludesInvoiceDiscountsAndCreditMemoContraRevenue`.
Verification of O1+O2: `dotnet build` clean; `dotnet test tests/PowderCoating.UnitTests`**291 passed**;
migration applied to the dev database successfully.
---
### O3 — Write-path account lookups omitted explicit `CompanyId` — **RESOLVED**
- Added `CompanyId` to every **GL posting-path** account lookup that determines where money posts:
- `InvoicesController` — all account-resolver helpers (`GetCheckingAccountIdAsync`,
`GetCustomerDepositsAccountIdAsync`, `GetSalesReturnsAccountIdAsync`, `GetCustomerCreditsAccountIdAsync`,
`GetArAccountIdAsync`, `GetBadDebtAccountIdAsync`, `ResolveSalesTaxAccountIdAsync`,
`GetSalesDiscountAccountIdAsync`, `GetGcLiabilityAccountIdAsync`) plus the bank-account and write-off
expense dropdowns (scoped via `_tenantContext`).
- `CreditMemosController` — Create/Apply/Void GL lookups (scoped via the in-scope `customer`/`invoice`/`memo`).
- `GiftCertificatesController` — Create, BulkCreate, Void GL lookups + `GetGcLiabilityAccountIdAsync`.
- `BillsController` — AP/expense account resolution that pre-fills `APAccountId` (Create + CreateFromPO).
- `DepositsController` and `JournalEntriesController.SalesTaxPayment` already scoped correctly.
### O4 — Sales-tax remittance could over-remit (drive 2200 negative) — **RESOLVED**
- `JournalEntriesController.SalesTaxPayment` (POST) now rejects any amount exceeding `taxAcct.CurrentBalance`
(0.005 rounding tolerance), so a typo can no longer push Sales Tax Payable into an abnormal debit balance.
Verification of O3+O4: `dotnet build` clean; `dotnet test tests/PowderCoating.UnitTests`**291 passed**.
### O5 — Gift Certificate Liability 2500 missing for new tenants / mislabeled on default company — **RESOLVED**
- **Root cause (same shape as O1):** 2500 is resolved by number as the GC liability
(`GiftCertificatesController`). The `AccountingGapsPhase2` migration seeded it for tenants existing at
deploy, but (a) the per-tenant seeder `SeedDataService.Accounts.cs` never created a 2500, so tenants
onboarded afterward had **no GC liability account** and GC GL postings silently no-op'd; and (b) the
default-company seeder `SeedData.cs` created 2500 as **"Long-Term Loan"**, so that company's GC
obligations were mislabeled (and the migration's `NOT EXISTS` guard skipped it).
- **Fix:**
- `SeedDataService.Accounts.cs` now seeds **2500 "Gift Certificate Liability" (IsSystem)**.
- `SeedData.cs` now seeds 2500 as GC liability and moves the long-term loan to **2900**.
- `EnsureSystemAccountsAsync` self-heals: renames any 2500 still named "Long-Term Loan" → "Gift
Certificate Liability" (preserving user renames) and ensures a 2500 exists.
- Migration `20260620002950_FixGiftCertificateLiabilityAccount`: moves long-term loan to 2900 where a
2500="Long-Term Loan" exists and no 2900 is present; relabels the mislabeled 2500; safety-net inserts a
2500 for any company lacking one. Non-destructive (no Id/number/balance changes); Down is best-effort.
- Verified on the dev DB: existing 2500 GC-liability rows preserved; no spurious accounts added; build
clean; migration applied; **291 unit tests pass**.
---
## Read-path account lookup sweep (Tier 13) — **RESOLVED**
Completed the app-wide defense-in-depth pass: every `Accounts` lookup in a controller now carries an
explicit `CompanyId` predicate, matching the standing rule in CLAUDE.md. ~19 lookups across 12 controllers:
- **Tier 1 (write-path validation):** `AccountsController` duplicate account-number check on Create/Edit.
- **Tier 2 (dropdowns/lists):** `AccountsController` (Index/year-end/parent dropdown), `BankReconciliations`,
`Bills` (bank list + receipt-scan + suggest), `Budgets`, `CatalogItems`, `Expenses`, `FixedAssets`,
`Inventory`, `JournalEntries` (chart dropdown), `Vendors`.
- **Tier 3 (`accountIds.Contains` display maps):** `JournalEntries` Details, `Reports` budget vs actual,
`VendorCredits` Details — scoped via the in-scope entity's `CompanyId` for uniformity.
`companyId` source per controller: `_tenantContext.GetCurrentCompanyId()` where available, else the
in-scope entity's `CompanyId`, else `_userManager` current user. Build clean; 291 unit tests pass.
---
## Whole-system re-audit (2026-06-19) — NEW findings, OPEN
Root cause shared by all three: the GL is kept two ways — **direct postings** via
`AccountBalanceService.Debit/CreditAsync` (authoritative `Account.CurrentBalance`) and **recomputes**
via `LedgerService` (drives `RecalculateAllAsync`) and `FinancialReportService` (drives the reports).
Every posting type must be mirrored in *both* recompute engines or balances diverge. Several postings
are **not** mirrored, so: (a) "Recalculate Balances" corrupts those accounts, (b) the Balance
Reconciliation report shows false drift, and in one case (O7) the **Trial Balance does not balance**.
O2 (Sales Discounts 4950) was the first instance found and fixed; these are the rest.
### O6 — Inventory consumption COGS not in the recompute — **RESOLVED (recompute approach)**
- `JobsController` and `InventoryController` post **DR COGS / CR Inventory** when an item with both
`CogsAccountId` and `InventoryAccountId` is consumed. The COGS posting fires only for `JobUsage` and
`Waste` transaction types, and those types are created **only** at the two COGS-posting sites — so the
consumption is exactly identifiable from `InventoryTransaction`. (This is why the recompute approach was
used instead of the originally-planned JE+backfill: it reads existing transactions, so historical data is
covered automatically with no fuzzy backfill.)
- **Fix applied:**
- Both posting sites now record the consumption at the effective (weighted-average) unit cost, so the
transaction's `TotalCost` equals the COGS actually posted (the recompute reads `TotalCost`).
- `LedgerService` (dated rows + prior balance): new section 12d — for a COGS or Inventory account, sum
`TotalCost` of JobUsage/Waste transactions on items with both accounts (DR COGS / CR Inventory).
- `FinancialReportService` **Trial Balance** (`cogsConsumptionByAcct` DR / `invConsumptionByAcct` CR) and
**P&L** (accrual COGS) include consumption. Cash-basis P&L excludes it (cash recognises cost at purchase).
- Regression test `GetAccountLedgerAsync_InventoryConsumption_DebitsCogsAndCreditsInventory`.
- **Deliberately NOT changed — see O9:** the **Balance Sheet** inventory-asset line is left as-is because it
already does not track inventory *purchases* (it expenses them through retained earnings), so relieving it
for consumption alone would drive inventory negative or unbalance the sheet. That's a separate inventory
*capitalization* policy decision (O9). TB and recalc are now correct and balanced; build clean; 293 tests pass.
### O7 — Gift-certificate redemptions credit AR but AR recompute omitted it — **RESOLVED**
- `InvoicesController.ApplyGiftCertificate:3137` posts **DR 2500 GC Liability / CR AR** (no Payment row,
no `AmountPaid` change — only `invoice.GiftCertificateRedeemed`). The **2500 debit IS** recomputed
(GC redemptions), but the **AR credit is NOT**: AR recompute = `sum(Total) Payments CreditMemoApplications`
in both `FinancialReportService` (BS line 358 / TB line 1129) and `LedgerService` (AR section).
- **Impact:** because only one side of the entry is recomputed, the **Trial Balance is out of balance by the
total GC redeemed**; Balance Sheet AR is overstated; recalc corrupts AR. (AR **Aging** is correct — it uses
`BalanceDue`, which includes `GiftCertificateRedeemed`.) Active for any company that redeems GCs on invoices.
- **Fix applied:** GC redemptions now subtracted from AR in both recompute engines — `FinancialReportService`
Balance Sheet (`gcRedeemedBs`) and Trial Balance (`gcRedeemedTb`), and `LedgerService` AR section + prior
balance. Mirrors the `cmApplied` treatment. Regression test
`GetAccountLedgerAsync_AR_GiftCertificateRedemption_CreditsAccountsReceivable`. Build clean; 292 tests pass.
### O8 — Written-off invoices misstated in AR recompute — **RESOLVED**
- `InvoicesController.WriteOff` already posts **DR Bad Debt / CR AR** *and creates a balanced posted
JournalEntry* — so both recompute engines already pick the entry up via their JE-line sections. The real
defect was narrower: `FinancialReportService` **excluded payments on written-off invoices** from AR credits
and bank debits (4× `Status != InvoiceStatus.WrittenOff` filters). Because the write-off JE only credits the
*unpaid balance*, excluding the earlier payments left the **paid** portion dangling as open AR (and
understated the bank). `LedgerService` had no such filter, so the two engines disagreed.
- **Fix applied:** removed all four `WrittenOff` exclusion filters in `FinancialReportService` (Balance Sheet
+ Trial Balance, both the bank-deposit and AR-credit queries). Now: invoice `Total` (debit) payments
(credit) write-off JE (credit) nets AR to zero, the payment counts in the bank, and bad debt is the JE
debit — trial balance balances, and the two engines agree. No backfill needed (the write-off JE already
exists for historical write-offs). AR Aging was already correct.
**Architectural note:** O2/O6/O7/O8 all stem from reports re-deriving balances from source documents in
parallel with the live postings. The durable fix is to make **every** financial event post `JournalEntry`
lines and drive all reports/recompute from those lines alone (single source of truth) — O8's write-off already
does exactly this, which is why it was the cleanest. Worth considering before the ledger grows further.
### O9 — Inventory accounting policy — **RESOLVED (policy decision: expense at purchase / periodic)**
- **Decision (owner, 2026-06-19):** materials (powder, consumables) are **expensed at purchase**, not
capitalized as inventory-for-resale — this is a service business that *uses* materials to deliver a
service, it does not sell inventory. So the **periodic** model is correct.
- **Implication — no code change needed:**
- The Balance Sheet correctly does **not** capitalize inventory (cost hits the P&L at purchase via the
bill line categorized to a COGS/expense account). The earlier decision to leave the BS untouched stands.
- Purchase receiving creates a `Purchase`-type `InventoryTransaction` that posts **no** GL — correct.
- The **perpetual** consumption-COGS path (O6: `DR COGS / CR Inventory` on JobUsage/Waste) is **opt-in**:
it only fires when an item has *both* `CogsAccountId` and `InventoryAccountId`, which are set **only via
CSV import** (never by normal item creation). Under this policy those mappings should be left empty, so
the path stays dormant. O6's recompute fix remains correct and harmless when unused.
- **⚠ Footgun to avoid:** do **not** both expense powder at purchase (bill → COGS account) *and* map an
item's `CogsAccountId` + `InventoryAccountId` — that would record the cost twice (once at purchase, once at
consumption). Keep item COGS/Inventory account mappings empty under the expense-at-purchase policy.
#### O9 update (2026-06-20) — default GL accounts feature widened this surface
- The original O9 note assumed item `CogsAccountId`/`InventoryAccountId` are "set only via CSV import, never
by normal item creation." **That assumption no longer holds.** A new **Default Accounts** feature
(Chart of Accounts → "Set Defaults", stored on `CompanyPreferences.Default{Revenue,Cogs,Inventory}AccountId`)
pre-fills these fields on **normal** Inventory/Catalog item creation. A company that sets *both* a default
COGS and a default Inventory account will have new items post `DR COGS / CR Inventory` on consumption —
i.e. it opts the shop into the perpetual path.
- **Why it's still safe:** the defaults are **null by default**, so nothing changes until a company
deliberately sets them. The footgun (double-counting under expense-at-purchase) is surfaced with an inline
warning on the Default Accounts card and in the Settings help article. Posting and balance-recompute both
read the item's *stored* accounts, so there is **no recompute drift** — verified during the 2026-06-20 audit.
- **Revenue default fallback:** invoice lines now fall back to `DefaultRevenueAccountId` (active only), then
to account 4000 (`InvoicesController.Create`). Resolved at invoice-create time and stored on the
`InvoiceItem`, so recompute stays consistent.
### 2026-06-20 audit — dropdown sub-type→type broadening + deposit account picker
Reviewed after broadening account dropdowns from sub-type to parent `AccountType` and adding a user-selectable
deposit account. **No ledger-drift bugs found.** Notes:
- **Deposit account picker ↔ recompute:** consistent. Live posting debits the chosen `DepositAccountId`, and
`LedgerService` reproduces the debit by `DepositAccountId == accountId` (lines ~78/724). Picking a non-default
deposit account recomputes correctly.
- **Bank / "pay-from" / bank-rec pickers — server-side guard added (2026-06-20):** the submitted account is
now validated via `AccountGuard.IsValidMoneyAccountAsync` (active, company-owned, AccountType Asset or
Liability) before any posting, at bill `RecordPayment` / `Create(payNow)` / `EditPayment`,
`BankReconciliation.Create`, and deposit `Record`. Defense in depth against tampered/stale POSTs. Per the
"trust the operator" decision this still allows e.g. A/R (an Asset) as a source — it only rejects
non-money types (Revenue/Expense/Equity/COGS).
- **Latent deposit imbalance — RESOLVED (2026-06-20):** a deposit saved with a null `DepositAccountId` posted
`CR 2300` with no offsetting debit → unbalanced. `DepositsController.Record` now blocks recording when the
`2300` Customer Deposits account exists but no deposit/bank account resolves (user must pick one). When `2300`
doesn't exist (company not using accounting), no GL posts at all, so the deposit is still allowed through.
- **Type/sub-type mismatch risk — RESOLVED (2026-06-20):** account `AccountType` is now **derived** from the
chosen `AccountSubType` on create/edit via `AccountClassification.TypeForSubType` (single source of truth,
also used by the Create pre-select), so the two can never disagree and the sub-type-based sign convention is
always consistent with the displayed type. A read-only sweep of the dev DB (109 accounts) found **0** existing
mismatches, so no repair tool was needed.
### 2026-06-20 — GL trial-balance integrity check (audit "#2")
Ran an empirical per-company trial-balance net on stored `Account.CurrentBalance`
(`SUM(debit-normal) SUM(credit-normal)`, which must net to 0 for balanced books). **Both tenants are
imbalanced**, but the cause is pre-existing data, **not** this session's changes.
- **Demo Company:** net Dr **$92,653.63** = **$89,500 opening-balance** entry without an offsetting equity
line (normal demo-data artifact) + **$3,153.63** from postings.
- **SCP Powder Coating** (opening balance $0, clean start): net Dr **$3,079.52**, entirely from postings.
**Root cause (forensics on SCP):** AR reconciles correctly (~invoices $21,496 payments $18,314 ≈ stored
$3,079), so AR posted on both sides. But **Revenue has $0 GL movement** (the 24 invoices are header-only —
**0 line items** — so the per-`InvoiceItem` revenue credit never fires) and the **payment-side bank debit
never posted** ($0 bank delta from 22 payments). This is the classic one-sided posting from
(a) imported/header-only invoices and (b) postings to unconfigured (null) offset accounts, which
`AccountBalanceService` silently skips. Same architectural class as O2/O6/O7/O8.
**Conclusion:** this session's work (default GL accounts, deposit guard, money-account guards, tenant sweep)
**did not introduce the imbalance** — those changes are read-path + validation + defaults and actually
*prevent* this bug class going forward (new invoices now fall back to a default revenue account; deposits/
payments are guarded against null money accounts). The existing imbalance is legacy/imported data.
**Remediation options (owner's call — not auto-applied; SCP is live company data):**
- `Recalculate Balances` re-derives from source docs but will **not** conjure revenue for header-only
invoices (no line items to credit), so it won't fully fix SCP on its own.
- Durable fix: the **JournalEntry single-source** refactor (`docs/ACCOUNTING_LEDGER_REFACTOR.md`) forces every
event to post balanced lines.
- Historical cleanup: correcting journal entries (or backfilling revenue/bank accounts on the imported docs
then recomputing) — a deliberate data-remediation task.
- Worth considering: surfacing this trial-balance net as a built-in "GL Health" indicator so drift is visible.
## Status
**All findings O1O9 + the read-path sweep are resolved** on `dev` (O9 by policy decision — expense at
purchase — needing no code change). The optional structural follow-up is the JournalEntry single-source
refactor (`docs/ACCOUNTING_LEDGER_REFACTOR.md`), which would prevent the O2/O6/O7/O8 bug class from recurring.
The 2026-06-20 trial-balance check confirmed a pre-existing GL imbalance from legacy/imported one-sided
postings (not from recent changes) — see above for remediation options.
Original audit numbering #13/#5/#6/#8 remains unrecoverable (see top). Nothing merged to `master` yet.
-86
View File
@@ -1,86 +0,0 @@
# Accounting Deploy & Verification Checklist
> Repeatable steps to run whenever accounting changes ship (and a good periodic health check).
> Goal: prove the books are internally consistent with **real data**, not just code review.
> Companions: `docs/ACCOUNTING_AUDIT.md` (findings/decisions), `docs/ACCOUNTING_LEDGER_REFACTOR.md` (future).
The decisive invariant: **a Trial Balance whose total debits == total credits, with no drift on the
Balance Reconciliation report, for every company.** If that holds, the ledger is sound by construction.
---
## 1. Pre-deploy (against the production DB, read-only)
Migrations are applied **manually** at deploy (the app does not auto-migrate). Two are pending from the
2026-06 audit; apply in this order:
1. `RenameDepositsAccountAddPayroll` (O1 — renames 2300 → "Customer Deposits", adds 2400 Payroll)
2. `FixGiftCertificateLiabilityAccount` (O5 — relabels mislabeled 2500 → GC Liability, adds 2900)
Both are non-destructive (no account Id / number / balance is changed; only relabels + additive inserts).
Preview exactly what they'll touch (read-only — swap account numbers as needed):
```sql
-- O1: 2300 rows that will be renamed (only those still named the default), and who gets a new 2400
SELECT CompanyId, AccountNumber, Name, CurrentBalance,
CASE WHEN Name = 'Payroll Liabilities' THEN 'WILL RENAME -> Customer Deposits' ELSE 'kept as-is' END AS Action
FROM Accounts WHERE AccountNumber = '2300' AND IsDeleted = 0 ORDER BY CompanyId;
-- O5: 2500 rows that will be relabeled (only those still named "Long-Term Loan")
SELECT CompanyId, AccountNumber, Name, CurrentBalance,
CASE WHEN Name = 'Long-Term Loan' THEN 'WILL RELABEL -> Gift Certificate Liability' ELSE 'kept as-is' END AS Action
FROM Accounts WHERE AccountNumber = '2500' AND IsDeleted = 0 ORDER BY CompanyId;
```
## 2. Deploy
1. Merge `dev``master`, trigger the Jenkins production job.
2. Apply the two migrations above (in order) to the production DB.
## 3. Post-deploy verification (per company — all of them)
Run for **every** company (there are ~7). Most is doable from the app UI under Reports / Finance.
- [ ] **Trial Balance** — open it. **Total Debits must equal Total Credits.** Any difference is a
one-sided posting and must be investigated before trusting other reports.
- [ ] **Balance Reconciliation report** (`/Reports/Reconciliation`) — every account's *stored* balance
should match the *recomputed* balance (no drift highlighted). Also confirm:
- AR control account == sum of customer balances (AR subledger).
- AP control account == sum of vendor balances (AP subledger).
- [ ] **Recalculate Balances**, then re-open the Trial Balance and Balance Reconciliation. This exercises
the recompute paths the audit fixed (`LedgerService`). After a recalc:
- Trial Balance still balances.
- Reconciliation shows no drift (stored now == recomputed by definition; the point is TB stays balanced
and the values look sane).
## 4. Spot-check the accounts the audit touched
For each company, glance at these on the Trial Balance / chart of accounts:
- [ ] **2300 Customer Deposits** — named correctly; balance == outstanding (un-applied) customer deposits.
- [ ] **2400 Payroll Liabilities** — exists (likely 0 unless payroll is tracked).
- [ ] **2500 Gift Certificate Liability** — named correctly; balance == outstanding GC value (issued redeemed voided).
- [ ] **2900 Long-Term Loan** — present where the old 2500 was relabeled.
- [ ] **4950 Sales Discounts / 4960 Sales Returns** — contra-revenue, show as debit-balance.
- [ ] **AR** — for any company that uses gift certificates or has written off an invoice, confirm AR is not
overstated (these were O7 / O8). Cross-check AR total against the AR Aging report.
## 5. If something is off
- A Trial Balance that doesn't balance → a posting hit only one side. Note the company + amount and check it
against the findings in `docs/ACCOUNTING_AUDIT.md` (the resolved O2/O6/O7/O8 patterns) before assuming a new bug.
- Drift on the Reconciliation report → run **Recalculate Balances**; if it persists, the recompute is missing a
posting type (same class as the audit findings).
- Do **not** treat a recalc as a fix for a real imbalance — it makes stored == recomputed, which can *hide* a
one-sided posting if only one engine is wrong. The Trial Balance balancing is the real test.
## 6. Policy reminders (from the audit)
- **Inventory = expensed at purchase (periodic).** Do **not** map an item's `CogsAccountId` + `InventoryAccountId`
(set only via CSV import) while also expensing powder at purchase — that double-counts COGS. Keep those empty.
- Sales-tax remittance is capped at the outstanding 2200 balance (O4) — you cannot over-remit.
## When to repeat
- After any accounting feature change or import.
- As a periodic health check (e.g. monthly), run Section 3 — it's cheap and catches drift early.
-118
View File
@@ -1,118 +0,0 @@
# Accounting Ledger Refactor — Single Source of Truth
> Status: **PROPOSED / not started.** This is a captured plan, not in-progress work.
> Companion to `docs/ACCOUNTING_AUDIT.md`. Resolves the O2/O6/O7/O8 bug *class* and the open
> finding **O9** (inventory capitalization). Do this when there's runway + a staging environment —
> it is not an emergency; after audit fixes O1O8 the current books are correct.
---
## Why
Today every financial event's GL effect is encoded in **three** places:
1. **`Account.CurrentBalance`** — updated live at each posting site via
`AccountBalanceService.DebitAsync/CreditAsync`.
2. **`LedgerService`** — re-derives each account's balance by reading *source documents* (Payments,
Invoices, Bills, InventoryTransactions, GiftCertificates, CreditMemos, …). Drives
`RecalculateAllAsync` and the Balance Reconciliation report.
3. **`FinancialReportService`** — *independently* re-derives P&L, Balance Sheet, and Trial Balance from
the same documents, with its own per-report logic (largely duplicated across the three reports).
Adding a transaction type means hand-mirroring its debit and credit into all three engines, in the right
direction, in every report method. Missing one spot produced every finding in the audit:
| Finding | Missed mirror |
|---|---|
| O2 | 4950 Sales Discounts absent from `LedgerService` → recalc wiped it |
| O6 | inventory-consumption COGS absent from both recompute engines |
| O7 | GC redemption's AR credit absent → **Trial Balance did not balance** |
| O8 | written-off payments filtered inconsistently between the two engines |
These are one design flaw with four faces: **reports re-interpret documents instead of reading a ledger.**
## Target
The journal becomes the only representation of GL effect. The tables already exist
(`JournalEntry` + `JournalEntryLine`, with a `Posted` status).
- **Every** financial event posts a balanced `JournalEntry` (DR == CR enforced once, centrally).
- `Account.CurrentBalance`, Trial Balance, Balance Sheet, P&L, and the recompute all derive from
**`JournalEntryLine` only** (plus each account's opening balance).
- Invoices/Payments/Bills remain *operational* records; they stop carrying implicit GL meaning that every
report must re-decode.
Result: `LedgerService.GetAccountLedgerAsync` (~800 lines of per-document sections) collapses to
"sum the JE lines for this account in range." Trial Balance / Balance Sheet become straight per-account JE
aggregations. The special cases — `gcRedeemed`, `cmApplied`, refund splits, consumption COGS, discount
contra-revenue — all disappear because they are simply journal lines.
## What changes in the code
1. **One posting service:** `PostAsync(date, description, lines[])` — validates balanced, assigns the
`JE-YYMM-####` number, marks `Posted`, updates `CurrentBalance` from the lines. `AccountBalanceService`
becomes a thin wrapper over it.
2. **Convert every posting site** to build JE lines instead of ad-hoc Debit/Credit pairs: invoice create,
payment, deposit record/apply, refund, credit memo issue/apply/void, GC issue/redeem/void, bill, bill
payment, vendor credit, inventory consumption, write-off, sales-tax remittance, year-end close,
depreciation, Stripe. Many already call `DebitAsync`; mechanical but touches **every accounting
controller**. `InvoicesController.WriteOff` already posts a JE — copy that model everywhere.
3. **Edits/voids become reversing entries** (post an opposite JE), never mutate in place.
4. **Gut the re-derivation:** `LedgerService` → JE-only; `FinancialReportService` TB/BS/P&L → JE-only.
AR/AP **aging** and customer/vendor statements stay document-driven (they answer "which documents are
open," not "what is the GL balance"). The AR control account then auto-reconciles to the subledger
because both come from the same postings.
## Migration of historical data
- **(a) Replay** — re-run every historical document through the new posting logic to emit JEs. Most
faithful; must handle edits/voids in chronological order. Complex, higher risk.
- **(b) Conversion balances (recommended)** — snapshot each account's current `CurrentBalance` as a single
dated "opening balance" JE at a cutover date; only new events post JEs after that. This is how real
systems migrate. With ~7 companies it is the pragmatic, low-risk path: pre-cutover per-transaction GL
granularity is lost, but every balance is preserved exactly.
## Phased plan (verifiable, not big-bang)
- **Phase 0** — build `PostAsync` + balanced-entry invariant + unit tests.
- **Phase 1** — route all posting sites through it, creating JEs *alongside* existing behavior (no report
changes yet). The **Balance Reconciliation report is the regression harness** — it should show near-zero
drift everywhere once every event posts a JE.
- **Phase 2** — switch `LedgerService` to JE-only; confirm `RecalculateAllAsync` reproduces balances.
- **Phase 3** — switch `FinancialReportService` (TB/BS/P&L) to JE-only; reconcile each report per company
against the old numbers using the snapshot harness below.
- **Phase 4** — post conversion-balance JEs at the cutover date; delete the dead re-derivation code
(~1,000+ lines).
- **Phase 5** — **O9 is already decided: expense at purchase (periodic).** Materials are used to deliver a
service, not sold, so they are expensed when purchased (bill → COGS/expense account); inventory is **not**
capitalized on the Balance Sheet. In the refactor, post material purchases as `DR COGS / CR AP` (expense
immediately) and do **not** emit a separate consumption JE. The opt-in perpetual path (item
`CogsAccountId` + `InventoryAccountId`) stays unused under this policy — keep those mappings empty to avoid
double-counting. No perpetual inventory asset/relief postings.
## Prerequisites before starting
- A **report-snapshot/diff harness** to compare each company's P&L / Balance Sheet / Trial Balance before
vs after each phase (the safety net for "numbers shifting").
- A **staging copy** of production data to run the cutover against first.
- A **decision on O9** (the inventory policy) — see `docs/ACCOUNTING_AUDIT.md`.
## Honest assessment
- **Effort:** the single biggest change in the accounting area — weeks, not hours, with careful per-company
verification.
- **Risk:** broad blast radius (every accounting controller); report numbers *will* shift slightly as they
become consistent (the point), so it needs per-company sign-off.
- **Optional:** after O1O8 the current system is correct. The refactor buys *prevention* of this bug class,
a real audit trail, and ~1,500 fewer lines of parallel logic. Higher ROI the more accounting features you
keep adding.
- **Safe stopping point:** Phases 01 alone (post JEs everywhere, watch the reconciliation report) are
low-risk and immediately valuable. You can stop there and continue later.
## What it fixes
- Eliminates the O2/O6/O7/O8 bug class (one place to get each event right).
- Trial Balance balances by construction.
- Every balance is traceable to a journal entry (audit trail).
- Resolves **O9** as part of Phase 5.
- Turns the Balance Reconciliation report into a true integrity check rather than a drift detector.
@@ -0,0 +1,8 @@
# Build output
bin/
obj/
# Transient scrape artifacts
*.tmp
*.invalid-*.bak
prismatic-sync.log
@@ -0,0 +1,43 @@
using Microsoft.Playwright;
namespace PrismaticSync.Infrastructure;
/// <summary>
/// A headless Chromium session with a realistic desktop fingerprint (UA, viewport, locale,
/// timezone) — matching the original scraper's settings to look like a normal browser.
/// </summary>
public sealed class BrowserSession : IAsyncDisposable
{
private IPlaywright? _pw;
private IBrowser? _browser;
private IBrowserContext? _context;
public IPage Page { get; private set; } = null!;
public static async Task<BrowserSession> CreateAsync(bool headed)
{
var session = new BrowserSession();
session._pw = await Playwright.CreateAsync();
session._browser = await session._pw.Chromium.LaunchAsync(new BrowserTypeLaunchOptions
{
Headless = !headed
});
session._context = await session._browser.NewContextAsync(new BrowserNewContextOptions
{
UserAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " +
"(KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36",
ViewportSize = new ViewportSize { Width = 1365, Height = 900 },
Locale = "en-US",
TimezoneId = "America/New_York"
});
session.Page = await session._context.NewPageAsync();
return session;
}
public async ValueTask DisposeAsync()
{
if (_context is not null) await _context.CloseAsync();
if (_browser is not null) await _browser.CloseAsync();
_pw?.Dispose();
}
}
@@ -0,0 +1,65 @@
using System.Text.Json;
using PrismaticSync.Models;
namespace PrismaticSync.Infrastructure;
/// <summary>Loads/saves the scrape output and the URL list, with atomic writes so a crash mid-save can't corrupt them.</summary>
public static class JsonStore
{
private static readonly JsonSerializerOptions WriteOptions = new() { WriteIndented = true };
private static readonly JsonSerializerOptions ReadOptions = new() { PropertyNameCaseInsensitive = true };
public static ScrapeOutput LoadOutput(string path)
{
if (!File.Exists(path))
return new ScrapeOutput();
var json = File.ReadAllText(path);
try
{
// Tolerate a bare array (older output format) as well as { results, errors }.
if (json.TrimStart().StartsWith("["))
{
var results = JsonSerializer.Deserialize<List<ProductRecord>>(json, ReadOptions) ?? new();
return new ScrapeOutput { Results = results };
}
return JsonSerializer.Deserialize<ScrapeOutput>(json, ReadOptions) ?? new ScrapeOutput();
}
catch (Exception ex)
{
var backup = $"{path}.invalid-{DateTimeOffset.UtcNow.ToUnixTimeSeconds()}.bak";
File.Copy(path, backup, overwrite: true);
throw new InvalidOperationException($"Could not parse {path}. Backed it up to {backup}. {ex.Message}");
}
}
public static void SaveOutput(string path, ScrapeOutput data)
{
var tmp = path + ".tmp";
File.WriteAllText(tmp, JsonSerializer.Serialize(data, WriteOptions));
File.Move(tmp, path, overwrite: true);
}
public static List<string> LoadUrls(string path)
{
if (!File.Exists(path))
return new List<string>();
return File.ReadAllLines(path)
.Select(CleanUrl)
.Where(u => u.Length > 0 && !u.StartsWith("#"))
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
}
public static void SaveUrls(string path, IEnumerable<string> urls)
{
var sorted = urls.Distinct(StringComparer.OrdinalIgnoreCase).OrderBy(u => u, StringComparer.OrdinalIgnoreCase);
var tmp = path + ".tmp";
File.WriteAllText(tmp, string.Join(Environment.NewLine, sorted) + Environment.NewLine);
File.Move(tmp, path, overwrite: true);
}
public static string CleanUrl(string? url) =>
(url ?? string.Empty).Split('?')[0].Split('#')[0].Trim();
}
@@ -0,0 +1,49 @@
namespace PrismaticSync.Infrastructure;
/// <summary>
/// Minimal timestamped logger — writes to the console and appends to a rolling log file so an
/// unattended (Task Scheduler) run leaves an audit trail. Intentionally dependency-free.
/// </summary>
public static class Log
{
private static string _logFile = "prismatic-sync.log";
private static readonly object Gate = new();
public static void Configure(string logFile) => _logFile = logFile;
public static void Info(string message) => Write("INFO", message);
public static void Warn(string message) => Write("WARN", message);
public static void Error(string message) => Write("ERROR", message);
private static void Write(string level, string message)
{
var line = $"[{DateTime.UtcNow:yyyy-MM-ddTHH:mm:ssZ}] {level,-5} {message}";
// Live console stream (visible on a manual run); color-code so warnings/errors stand out.
lock (Gate)
{
var color = level switch
{
"WARN" => ConsoleColor.Yellow,
"ERROR" => ConsoleColor.Red,
_ => (ConsoleColor?)null
};
if (color is { } c)
{
var previous = Console.ForegroundColor;
Console.ForegroundColor = c;
Console.WriteLine(line);
Console.ForegroundColor = previous;
}
else
{
Console.WriteLine(line);
}
// File trail — never let logging break a run.
try { File.AppendAllText(_logFile, line + Environment.NewLine); }
catch { /* ignore */ }
}
}
}
@@ -0,0 +1,69 @@
namespace PrismaticSync.Infrastructure;
/// <summary>Strongly-typed config bound from the "Sync" section of appsettings.json.</summary>
public class SyncConfig
{
public string BaseUrl { get; set; } = "https://www.prismaticpowders.com";
public string ColorsPath { get; set; } = "/shop/powder-coating-colors";
public string ProductUrlsFile { get; set; } = "product-urls.txt";
public string OutputJsonFile { get; set; } = "prismatic_powders.json";
public string LogFile { get; set; } = "prismatic-sync.log";
/// <summary>Politeness delay between product scrapes (randomized within the range).</summary>
public int MinDelaySeconds { get; set; } = 6;
public int MaxDelaySeconds { get; set; } = 14;
/// <summary>On a 403/block, cool down this many seconds × the consecutive-block count, then retry.</summary>
public int BlockedCooldownSeconds { get; set; } = 120;
/// <summary>Upper bound on a single cooldown so escalation can't run away.</summary>
public int BlockedCooldownMaxSeconds { get; set; } = 600;
/// <summary>How many times to cool-down-and-retry a blocked product before recording it as an error.</summary>
public int BlockedMaxRetries { get; set; } = 3;
/// <summary>Take a longer rest after this many products (0 disables). Eases load and looks less robotic.</summary>
public int LongRestEveryProducts { get; set; } = 150;
/// <summary>Length of the periodic long rest, in seconds.</summary>
public int LongRestSeconds { get; set; } = 45;
/// <summary>Extra settle time after a product page loads before reading it.</summary>
public int PageSettleSeconds { get; set; } = 4;
/// <summary>Pause after each scroll while a listing lazy-loads more items.</summary>
public int ScrollWaitMs { get; set; } = 1500;
/// <summary>Hard cap on scrolls per listing, as a safety stop.</summary>
public int MaxScrolls { get; set; } = 400;
/// <summary>Full discovery: stop a listing after this many scrolls add no new links.</summary>
public int StopAfterNoNewScrolls { get; set; } = 10;
/// <summary>
/// Incremental discovery: stop the newest-first listing after this many consecutive scrolls
/// that surfaced only already-known URLs — i.e. we've scrolled past the new products.
/// </summary>
public int StopAfterKnownScrolls { get; set; } = 8;
/// <summary>Color filter params used by full discovery.</summary>
public string[] ColorParams { get; set; } = Array.Empty<string>();
public ImportConfig Import { get; set; } = new();
public string ColorsUrl => $"{BaseUrl.TrimEnd('/')}{ColorsPath}";
}
/// <summary>Where and how to push the scraped catalog into the app.</summary>
public class ImportConfig
{
/// <summary>Full URL of the app's token-authenticated catalog import endpoint.</summary>
public string EndpointUrl { get; set; } = "";
/// <summary>Shared secret sent in the X-Import-Token header. Must match the app's config.</summary>
public string Token { get; set; } = "";
/// <summary>Vendor name applied to every record on import.</summary>
public string VendorName { get; set; } = "Prismatic Powders";
}
@@ -0,0 +1,45 @@
using System.Text.Json.Serialization;
namespace PrismaticSync.Models;
/// <summary>
/// On-disk scrape output. Shape matches the app's catalog import (a top-level "results" array of
/// snake_case product records), so the JSON drops straight into the import endpoint. "errors" tracks
/// failed URLs for resumable re-runs.
/// </summary>
public class ScrapeOutput
{
[JsonPropertyName("results")] public List<ProductRecord> Results { get; set; } = new();
[JsonPropertyName("errors")] public List<ScrapeError> Errors { get; set; } = new();
}
/// <summary>One scraped product, in the import's expected field shape.</summary>
public class ProductRecord
{
[JsonPropertyName("sku")] public string Sku { get; set; } = "";
[JsonPropertyName("color_name")] public string ColorName { get; set; } = "";
[JsonPropertyName("description")] public string Description { get; set; } = "";
[JsonPropertyName("price_tiers")] public List<PriceTier> PriceTiers { get; set; } = new();
[JsonPropertyName("safety_data_sheet_url")] public string SafetyDataSheetUrl { get; set; } = "";
[JsonPropertyName("technical_data_sheet_url")] public string TechnicalDataSheetUrl { get; set; } = "";
[JsonPropertyName("application_guide_url")] public string ApplicationGuideUrl { get; set; } = "";
[JsonPropertyName("sample_image_url")] public string SampleImageUrl { get; set; } = "";
[JsonPropertyName("product_url")] public string ProductUrl { get; set; } = "";
[JsonPropertyName("scraped_at")] public DateTime ScrapedAt { get; set; }
}
/// <summary>A quantity-break price tier — {min, max, price}. max is null for an open-ended top tier.</summary>
public class PriceTier
{
[JsonPropertyName("min")] public int? Min { get; set; }
[JsonPropertyName("max")] public int? Max { get; set; }
[JsonPropertyName("price")] public decimal Price { get; set; }
}
/// <summary>A URL that failed to scrape, kept so resumable runs can skip or retry it.</summary>
public class ScrapeError
{
[JsonPropertyName("product_url")] public string ProductUrl { get; set; } = "";
[JsonPropertyName("error")] public string Error { get; set; } = "";
[JsonPropertyName("scraped_at")] public DateTime ScrapedAt { get; set; }
}
@@ -0,0 +1,36 @@
<Project Sdk="Microsoft.NET.Sdk">
<!--
Standalone workstation tool — deliberately NOT part of PowderCoating.sln.
Build/publish independently and run on a machine you control (Task Scheduler),
never on the deployed app server. Scrapes Prismatic Powders and pushes the
result into the app's catalog import endpoint.
First-time setup on a workstation:
dotnet build
pwsh bin/Debug/net8.0/playwright.ps1 install chromium
-->
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<AssemblyName>PrismaticSync</AssemblyName>
<RootNamespace>PrismaticSync</RootNamespace>
<InvariantGlobalization>true</InvariantGlobalization>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Playwright" Version="1.49.0" />
<PackageReference Include="Microsoft.Extensions.Configuration" Version="8.0.0" />
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="8.0.1" />
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" Version="8.0.2" />
</ItemGroup>
<ItemGroup>
<None Update="appsettings.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
</ItemGroup>
</Project>
+106
View File
@@ -0,0 +1,106 @@
using Microsoft.Extensions.Configuration;
using PrismaticSync.Infrastructure;
using PrismaticSync.Services;
// ── Load config ───────────────────────────────────────────────────────────────
var configRoot = new ConfigurationBuilder()
.SetBasePath(AppContext.BaseDirectory)
.AddJsonFile("appsettings.json", optional: false)
.Build();
var config = configRoot.GetSection("Sync").Get<SyncConfig>() ?? new SyncConfig();
Log.Configure(config.LogFile);
// ── Parse args ────────────────────────────────────────────────────────────────
var command = args.Length > 0 && !args[0].StartsWith("--") ? args[0].ToLowerInvariant() : "run";
var headed = args.Contains("--headed");
var retryErrors = args.Contains("--retry-errors");
var maxProducts = GetIntArg("--max-products", 0);
// "run" refreshes products older than 30 days by default; explicit commands default to new-only.
var refreshOlderThanDays = GetIntArg("--refresh-older-than", command == "run" ? 30 : 0);
Log.Info($"PrismaticSync — command '{command}' (headed={headed}, refreshOlderThan={refreshOlderThanDays}d, maxProducts={maxProducts})");
try
{
switch (command)
{
case "discover-new":
await WithBrowser(d => new PrismaticDiscoverer(d, config).DiscoverNewAsync());
break;
case "discover-full":
await WithBrowser(d => new PrismaticDiscoverer(d, config).DiscoverFullAsync());
break;
case "scrape":
await WithBrowser(d => new PrismaticScraper(d, config).ScrapeAsync(refreshOlderThanDays, maxProducts, retryErrors));
break;
case "push":
await new CatalogPusher(config).PushAsync();
break;
case "run":
// The scheduled default: find new colors, scrape new + stale, then push.
await WithBrowser(async d =>
{
await new PrismaticDiscoverer(d, config).DiscoverNewAsync();
await new PrismaticScraper(d, config).ScrapeAsync(refreshOlderThanDays, maxProducts, retryErrors);
});
await new CatalogPusher(config).PushAsync();
break;
default:
PrintUsage();
return 1;
}
Log.Info("Done.");
return 0;
}
catch (Exception ex)
{
Log.Error($"Fatal: {ex}");
return 1;
}
// ── Helpers ───────────────────────────────────────────────────────────────────
async Task WithBrowser(Func<BrowserSession, Task> action)
{
await using var session = await BrowserSession.CreateAsync(headed);
await action(session);
}
int GetIntArg(string name, int fallback)
{
var prefix = name + "=";
var found = args.FirstOrDefault(a => a.StartsWith(prefix, StringComparison.OrdinalIgnoreCase));
return found is not null && int.TryParse(found[prefix.Length..], out var value) ? value : fallback;
}
void PrintUsage()
{
Console.WriteLine(
"""
PrismaticSync scrape Prismatic Powders and push to the app catalog.
Usage: PrismaticSync [command] [options]
Commands:
run (default) discover-new + scrape (new + stale) + push
discover-new Incremental discovery via newest-first sort (cheap; finds new colors)
discover-full Full discovery across all color filters (heavy; reconciles the whole set)
scrape Scrape product pages from the URL list (resumable)
push Push the scraped JSON to the import endpoint
Options:
--refresh-older-than=N Re-scrape products whose data is older than N days (default 30 for 'run')
--max-products=N Cap products scraped this run (0 = no cap)
--retry-errors Retry URLs previously recorded as errors
--headed Show the browser window (debugging)
Config: appsettings.json (delays, file paths, import endpoint + token).
First run on a new machine: dotnet build, then `pwsh bin/Debug/net8.0/playwright.ps1 install chromium`.
""");
}
+86
View File
@@ -0,0 +1,86 @@
# PrismaticSync
A standalone .NET console tool that scrapes the Prismatic Powders catalog and pushes it into the
Powder Coating Logix catalog import endpoint. It exists because Prismatic has **no API** (unlike
Columbia Coatings) — so the data has to be scraped via browser automation.
> **Runs on a workstation you control — never on the deployed app server.** Scraping from the cloud
> app's IP would get blocked and isn't appropriate. This tool is deliberately *not* part of
> `PowderCoating.sln`; build and run it independently.
## First-time setup (per machine)
```powershell
cd "scripts/Prismatic Data Scraper"
dotnet build
pwsh bin/Debug/net8.0/playwright.ps1 install chromium # one-time browser download
```
## Commands
```powershell
dotnet run -- run # default: discover-new + scrape (new + stale >30d) + push
dotnet run -- discover-new # cheap: find newly-added colors (newest-first, stops at known)
dotnet run -- discover-full # heavy: crawl all color filters (reconcile whole set / removals)
dotnet run -- scrape # scrape product pages from product-urls.txt (resumable)
dotnet run -- scrape --refresh-older-than=30 # also re-scrape products older than 30 days (price changes)
dotnet run -- push # push prismatic_powders.json to the import endpoint
```
Options: `--max-products=N`, `--retry-errors`, `--headed` (show the browser for debugging).
Everything streams to the console live (warnings/errors in color) **and** to `prismatic-sync.log`.
## Operating model (suggested cadence)
| Run | Command | Cadence | Why |
|-----|---------|---------|-----|
| Find new colors | `run` (does discover-new + scrape-new) | Weekly | Cheap; Prismatic adds colors often |
| Price refresh | `scrape --refresh-older-than=30` then `push` | Monthly | Re-scrapes stale products to catch price changes (slow, ~hours) |
| Full reconcile | `discover-full` then `scrape` | Quarterly | Catches removed/discontinued colors |
A full scrape of ~5,000 products takes hours (polite delays). It saves after every product and is
fully resumable, so stop/restart any time.
## Politeness / anti-block
Configurable in `appsettings.json`: randomized 614s base delay, an escalating **cooldown + retry on
403** (so a temporary block doesn't get you hard-banned mid-run), and a periodic long rest. Leave
these conservative — getting blocked is worse than being slow, and Prismatic is a partner.
## Pushing into the app
Set in `appsettings.json`:
- `Sync.Import.EndpointUrl``https://<your-app>/PowderCatalog/ImportApi`
- `Sync.Import.Token` → the same secret as the app's `CatalogImport:Token` config
The tool POSTs the JSON with an `X-Import-Token` header (and `X-Vendor-Name: Prismatic Powders`) to
that endpoint, which authenticates the token and runs the records through the same upsert as the
Columbia sync. If the endpoint/token isn't configured here, `push` is skipped and you upload
`prismatic_powders.json` manually via the Powder Catalog admin page instead.
> **App side:** set `CatalogImport:Token` in the web app's config (Azure App Setting in prod). The
> endpoint returns 401 until a token is set, so it's inert by default.
## Scheduling (Windows Task Scheduler)
Point a scheduled task at the published exe (or `dotnet run`). Example weekly task command:
```
Program/script: C:\Tools\PrismaticSync\PrismaticSync.exe
Arguments: run
Start in: C:\Tools\PrismaticSync
```
Publish a self-contained build to drop on the workstation:
```powershell
dotnet publish -c Release -r win-x64 --self-contained false -o C:\Tools\PrismaticSync
pwsh C:\Tools\PrismaticSync\playwright.ps1 install chromium
```
## The long game
This is the interim path. The durable endgame is a real Prismatic **API** (the partnership), at which
point this tool is replaced by a clean in-app sync like Columbia's — reusing the same upsert,
propagation, and discontinued handling.
@@ -0,0 +1,63 @@
using System.Text;
using PrismaticSync.Infrastructure;
namespace PrismaticSync.Services;
/// <summary>
/// Pushes the scraped JSON to the app's token-authenticated catalog import endpoint. When no
/// endpoint is configured it no-ops (the JSON is still on disk for a manual upload), so the tool is
/// useful before the endpoint exists.
/// </summary>
public class CatalogPusher
{
private readonly SyncConfig _config;
public CatalogPusher(SyncConfig config) => _config = config;
public async Task<bool> PushAsync()
{
if (string.IsNullOrWhiteSpace(_config.Import.EndpointUrl))
{
Log.Warn($"No import endpoint configured (Sync.Import.EndpointUrl) — skipping push. " +
$"Upload {_config.OutputJsonFile} manually via the Powder Catalog admin instead.");
return false;
}
if (!File.Exists(_config.OutputJsonFile))
{
Log.Warn($"Output file {_config.OutputJsonFile} not found — nothing to push.");
return false;
}
var json = await File.ReadAllTextAsync(_config.OutputJsonFile);
Log.Info($"Pushing {_config.OutputJsonFile} to {_config.Import.EndpointUrl} (vendor: {_config.Import.VendorName})...");
using var http = new HttpClient { Timeout = TimeSpan.FromMinutes(5) };
using var request = new HttpRequestMessage(HttpMethod.Post, _config.Import.EndpointUrl);
request.Headers.Add("X-Import-Token", _config.Import.Token);
request.Headers.Add("X-Vendor-Name", _config.Import.VendorName);
request.Content = new StringContent(json, Encoding.UTF8, "application/json");
try
{
using var response = await http.SendAsync(request);
var body = await response.Content.ReadAsStringAsync();
if (response.IsSuccessStatusCode)
{
Log.Info($"Push succeeded ({(int)response.StatusCode}): {Trim(body)}");
return true;
}
Log.Error($"Push failed ({(int)response.StatusCode}): {Trim(body)}");
return false;
}
catch (Exception ex)
{
Log.Error($"Push error: {ex.Message}");
return false;
}
}
private static string Trim(string s) => s.Length > 500 ? s[..500] + "…" : s;
}
@@ -0,0 +1,138 @@
using System.Text.RegularExpressions;
using Microsoft.Playwright;
using PrismaticSync.Infrastructure;
namespace PrismaticSync.Services;
/// <summary>
/// Discovers product URLs from the Prismatic color listing (infinite-scroll). Two modes:
/// incremental (newest-first via <c>?category=created_at</c>, stop once we reach already-known
/// URLs) for cheap frequent runs, and full (every color filter to the bottom) for occasional
/// reconciliation. Both append to the URL list file.
/// </summary>
public class PrismaticDiscoverer
{
private static readonly Regex ProductUrlRegex =
new(@"/shop/powder-coating-colors/[A-Z0-9-]+/", RegexOptions.IgnoreCase | RegexOptions.Compiled);
private readonly BrowserSession _session;
private readonly SyncConfig _config;
public PrismaticDiscoverer(BrowserSession session, SyncConfig config)
{
_session = session;
_config = config;
}
/// <summary>
/// Incremental discovery: crawl the newest-first listing and stop once a run of consecutive
/// scrolls surfaces only already-known URLs — meaning we've scrolled past the new products.
/// Returns the count of newly found URLs.
/// </summary>
public async Task<int> DiscoverNewAsync()
{
var known = new HashSet<string>(JsonStore.LoadUrls(_config.ProductUrlsFile), StringComparer.OrdinalIgnoreCase);
var startCount = known.Count;
Log.Info($"Incremental discovery (newest first). Known URLs: {startCount}");
await GotoAsync($"{_config.ColorsUrl}?category=created_at");
var knownStreak = 0;
for (var i = 0; i < _config.MaxScrolls; i++)
{
var addedNew = 0;
foreach (var link in await CollectProductLinksAsync())
if (known.Add(link)) addedNew++;
JsonStore.SaveUrls(_config.ProductUrlsFile, known);
knownStreak = addedNew == 0 ? knownStreak + 1 : 0;
Log.Info($"Scroll {i + 1}: +{addedNew} new, total {known.Count}, known-streak {knownStreak}");
if (knownStreak >= _config.StopAfterKnownScrolls)
{
Log.Info("Reached known territory — stopping incremental discovery.");
break;
}
await ScrollAsync();
}
var newCount = known.Count - startCount;
Log.Info($"Incremental discovery done. New URLs: {newCount}; total {known.Count}");
return newCount;
}
/// <summary>
/// Full discovery: crawl every color filter to the bottom. Heavier — use occasionally to
/// reconcile the whole set (e.g. to notice colors that have been removed). Returns new URL count.
/// </summary>
public async Task<int> DiscoverFullAsync()
{
var known = new HashSet<string>(JsonStore.LoadUrls(_config.ProductUrlsFile), StringComparer.OrdinalIgnoreCase);
var startCount = known.Count;
Log.Info($"Full discovery across {_config.ColorParams.Length} color filters. Known URLs: {startCount}");
foreach (var color in _config.ColorParams)
{
Log.Info($"Color filter: {color}");
try
{
await GotoAsync($"{_config.ColorsUrl}?color={Uri.EscapeDataString(color)}");
var noNew = 0;
for (var i = 0; i < _config.MaxScrolls; i++)
{
var added = 0;
foreach (var link in await CollectProductLinksAsync())
if (known.Add(link)) added++;
JsonStore.SaveUrls(_config.ProductUrlsFile, known);
noNew = added == 0 ? noNew + 1 : 0;
if (noNew >= _config.StopAfterNoNewScrolls)
break;
await ScrollAsync();
}
Log.Info($"Color {color} done. Total {known.Count}");
await _session.Page.WaitForTimeoutAsync(3000);
}
catch (Exception ex)
{
Log.Warn($"Color {color} failed: {ex.Message}");
}
}
var newCount = known.Count - startCount;
Log.Info($"Full discovery done. New this run: {newCount}; total {known.Count}");
return newCount;
}
private async Task GotoAsync(string url)
{
await _session.Page.GotoAsync(url, new PageGotoOptions
{
WaitUntil = WaitUntilState.DOMContentLoaded,
Timeout = 60000
});
await _session.Page.WaitForTimeoutAsync(_config.PageSettleSeconds * 1000);
}
private async Task ScrollAsync()
{
await _session.Page.Mouse.WheelAsync(0, 2500);
await _session.Page.WaitForTimeoutAsync(_config.ScrollWaitMs);
}
private async Task<List<string>> CollectProductLinksAsync()
{
var hrefs = await _session.Page.EvalOnSelectorAllAsync<string[]>(
"a", "els => els.map(a => a.href).filter(Boolean)");
return hrefs
.Where(h => ProductUrlRegex.IsMatch(h))
.Select(JsonStore.CleanUrl)
.Where(u => u.Length > 0)
.ToList();
}
}
@@ -0,0 +1,308 @@
using System.Diagnostics;
using System.Globalization;
using System.Text.RegularExpressions;
using Microsoft.Playwright;
using PrismaticSync.Infrastructure;
using PrismaticSync.Models;
namespace PrismaticSync.Services;
/// <summary>
/// Scrapes individual Prismatic product pages into <see cref="ProductRecord"/>s. Resumable (skips
/// already-scraped URLs, optionally retries past errors) and supports a refresh window so stale
/// records get re-scraped to catch price changes. Saves after every product so a long run can be
/// stopped and resumed safely, and logs continuously — including the delay between products — so a
/// manual run always shows it's alive.
/// </summary>
public class PrismaticScraper
{
private static readonly Regex ProductUrlRegex =
new(@"/shop/powder-coating-colors/[A-Z0-9-]+/", RegexOptions.IgnoreCase | RegexOptions.Compiled);
private static readonly Regex SkuRegex =
new(@"Item:\s*([A-Z0-9-]+)", RegexOptions.IgnoreCase | RegexOptions.Compiled);
private static readonly Regex DescRegex =
new(@"Description:\s*(.*?)(WARNING:|What does this match\?|PRODUCT SUPPORT|PRODUCT COLLECTIONS|CUSTOMER SERVICE|$)",
RegexOptions.IgnoreCase | RegexOptions.Singleline | RegexOptions.Compiled);
private static readonly Regex PriceTierRegex =
new(@"(\d+\s*-\s*\d+\s*lbs|\d+\s*\+\s*lbs)\s*\$([\d.]+)", RegexOptions.IgnoreCase | RegexOptions.Compiled);
private static readonly Regex RangeRegex = new(@"(\d+)\s*-\s*(\d+)", RegexOptions.Compiled);
private static readonly Regex PlusRegex = new(@"(\d+)\s*\+", RegexOptions.Compiled);
private static readonly Regex WhitespaceRegex = new(@"\s+", RegexOptions.Compiled);
private readonly BrowserSession _session;
private readonly SyncConfig _config;
private readonly Random _random = new();
public PrismaticScraper(BrowserSession session, SyncConfig config)
{
_session = session;
_config = config;
}
/// <summary>
/// Scrapes products needing work: those not yet scraped, plus (when <paramref name="refreshOlderThanDays"/>
/// &gt; 0) any whose data is older than that window. Returns (scraped, errors).
/// </summary>
public async Task<(int Scraped, int Errors)> ScrapeAsync(int refreshOlderThanDays, int maxProducts, bool retryErrors)
{
var allUrls = JsonStore.LoadUrls(_config.ProductUrlsFile)
.Where(u => ProductUrlRegex.IsMatch(u))
.ToList();
var data = JsonStore.LoadOutput(_config.OutputJsonFile);
// Index existing results by URL (keep the most recent if the file has dupes).
var resultByUrl = data.Results
.GroupBy(r => JsonStore.CleanUrl(r.ProductUrl), StringComparer.OrdinalIgnoreCase)
.ToDictionary(g => g.Key, g => g.OrderByDescending(r => r.ScrapedAt).First(), StringComparer.OrdinalIgnoreCase);
var errorUrls = new HashSet<string>(
data.Errors.Select(e => JsonStore.CleanUrl(e.ProductUrl)), StringComparer.OrdinalIgnoreCase);
var staleCutoff = DateTime.UtcNow.AddDays(-Math.Max(0, refreshOlderThanDays));
var toScrape = new List<string>();
foreach (var url in allUrls)
{
if (resultByUrl.TryGetValue(url, out var existing))
{
if (refreshOlderThanDays > 0 && existing.ScrapedAt < staleCutoff)
toScrape.Add(url); // stale → refresh for price changes
}
else
{
if (retryErrors || !errorUrls.Contains(url))
toScrape.Add(url); // never scraped (skip known errors unless retrying)
}
}
if (maxProducts > 0)
toScrape = toScrape.Take(maxProducts).ToList();
var total = toScrape.Count;
Log.Info($"URLs: {allUrls.Count}; already scraped: {resultByUrl.Count}; errors on file: {errorUrls.Count}");
Log.Info($"To scrape this run: {total} (refresh older than {refreshOlderThanDays}d, retry errors: {retryErrors})");
if (total == 0)
{
Log.Info("Nothing to scrape. Done.");
return (0, 0);
}
var avgDelaySec = (_config.MinDelaySeconds + _config.MaxDelaySeconds) / 2.0;
var etaMinutes = total * (avgDelaySec + _config.PageSettleSeconds + 2) / 60.0;
Log.Info($"Estimated run time: ~{FormatDuration(TimeSpan.FromMinutes(etaMinutes))} " +
$"(grab a coffee if that's a while — it saves after every product and is resumable).");
var stopwatch = Stopwatch.StartNew();
int scraped = 0, errors = 0, index = 0, consecutiveBlocks = 0;
foreach (var url in toScrape)
{
index++;
for (var attempt = 1; ; attempt++)
{
try
{
var row = await ParseProductAsync(url, index, total);
if (resultByUrl.TryGetValue(url, out var existing))
data.Results[data.Results.IndexOf(existing)] = row;
else
data.Results.Add(row);
resultByUrl[url] = row;
data.Errors.RemoveAll(e => JsonStore.CleanUrl(e.ProductUrl).Equals(url, StringComparison.OrdinalIgnoreCase));
scraped++;
consecutiveBlocks = 0;
JsonStore.SaveOutput(_config.OutputJsonFile, data);
var basePrice = row.PriceTiers.Count > 0 ? row.PriceTiers.Min(t => t.Price) : 0m;
Log.Info($"[{index}/{total}] Saved {row.Sku} \"{row.ColorName}\" " +
$"({row.PriceTiers.Count} tier(s), base ${basePrice:0.00}) | elapsed {FormatDuration(stopwatch.Elapsed)}");
break;
}
catch (Exception ex) when (IsBlocked(ex) && attempt <= _config.BlockedMaxRetries)
{
// Site pushed back — back off (escalating) and retry the SAME product rather
// than barreling on, which is how an unattended run gets hard-banned.
consecutiveBlocks++;
var cooldown = Math.Min(_config.BlockedCooldownSeconds * consecutiveBlocks, _config.BlockedCooldownMaxSeconds);
Log.Warn($"[{index}/{total}] Blocked (403), attempt {attempt}. Cooling down {cooldown}s, then retrying this product...");
await Task.Delay(cooldown * 1000);
}
catch (Exception ex)
{
data.Errors.Add(new ScrapeError { ProductUrl = url, Error = ex.Message, ScrapedAt = DateTime.UtcNow });
JsonStore.SaveOutput(_config.OutputJsonFile, data);
errors++;
Log.Error($"[{index}/{total}] {url} -> {ex.Message}");
break;
}
}
// Periodic longer rest — eases server load and avoids a robotic, evenly-spaced cadence.
if (_config.LongRestEveryProducts > 0 && index % _config.LongRestEveryProducts == 0 && index < total)
{
Log.Info($"Resting {_config.LongRestSeconds}s after {index} products...");
await Task.Delay(_config.LongRestSeconds * 1000);
}
if (index < total)
{
var delayMs = RandomDelayMs();
Log.Info($"[{index}/{total}] Waiting {delayMs / 1000.0:0.0}s before next product...");
await Task.Delay(delayMs);
}
}
Log.Info($"Scrape complete. Scraped {scraped}, errors {errors}. Total results on file: {data.Results.Count}. " +
$"Took {FormatDuration(stopwatch.Elapsed)}.");
return (scraped, errors);
}
private async Task<ProductRecord> ParseProductAsync(string url, int index, int total)
{
Log.Info($"[{index}/{total}] Scraping {url}");
var response = await _session.Page.GotoAsync(url, new PageGotoOptions
{
WaitUntil = WaitUntilState.DOMContentLoaded,
Timeout = 60000
});
await _session.Page.WaitForTimeoutAsync(_config.PageSettleSeconds * 1000);
var status = response?.Status ?? 0;
var title = Clean(await SafeTextAsync(() => _session.Page.TitleAsync()));
var plainText = Clean(await SafeTextAsync(() => _session.Page.Locator("body").InnerTextAsync()));
if (status == 403 || Regex.IsMatch(title, @"^403 Forbidden$", RegexOptions.IgnoreCase))
throw new Exception("403 Forbidden returned by site.");
if (status == 404 || Regex.IsMatch(title, @"404|Page Not Found", RegexOptions.IgnoreCase))
throw new Exception("404 Not Found returned by site.");
var colorName = Clean(await SafeTextAsync(() => _session.Page.Locator("h1").First.InnerTextAsync()));
var skuMatch = SkuRegex.Match(plainText);
var sku = skuMatch.Success ? skuMatch.Groups[1].Value : "";
if (string.IsNullOrEmpty(sku) && string.IsNullOrEmpty(colorName))
throw new Exception("Could not find SKU or title on product page.");
var descMatch = DescRegex.Match(plainText);
var description = descMatch.Success ? Clean(descMatch.Groups[1].Value) : "";
return new ProductRecord
{
Sku = sku,
ColorName = colorName,
Description = description,
PriceTiers = ParsePriceTiers(plainText),
SafetyDataSheetUrl = await GetLinkByTextAsync(new[] { "Safety Data Sheet", @"\bSDS\b" }),
TechnicalDataSheetUrl = await GetLinkByTextAsync(new[] { "Tech Data Sheet", "Technical Data Sheet", @"\bTDS\b" }),
ApplicationGuideUrl = await GetLinkByTextAsync(new[] { "Application Guide" }),
SampleImageUrl = await GetSampleImageUrlAsync(),
ProductUrl = url,
ScrapedAt = DateTime.UtcNow
};
}
private static List<PriceTier> ParsePriceTiers(string text)
{
var tiers = new List<PriceTier>();
foreach (Match m in PriceTierRegex.Matches(text))
{
if (!decimal.TryParse(m.Groups[2].Value, NumberStyles.Any, CultureInfo.InvariantCulture, out var price))
continue;
var rangeText = Clean(m.Groups[1].Value);
int? min = null, max = null;
var range = RangeRegex.Match(rangeText);
if (range.Success)
{
min = int.Parse(range.Groups[1].Value);
max = int.Parse(range.Groups[2].Value);
}
var plus = PlusRegex.Match(rangeText);
if (plus.Success)
{
min = int.Parse(plus.Groups[1].Value);
max = null;
}
tiers.Add(new PriceTier { Min = min, Max = max, Price = price });
}
return tiers;
}
/// <summary>Returns the href of the first link whose text matches any pattern. Uses a single eval
/// returning "texthref" pairs to avoid object deserialization quirks.</summary>
private async Task<string> GetLinkByTextAsync(string[] patterns)
{
var combined = await _session.Page.EvalOnSelectorAllAsync<string[]>(
"a",
"els => els.map(a => ((a.innerText || a.textContent || '').replace(/\\s+/g, ' ').trim()) " +
"+ String.fromCharCode(1) + (a.href || ''))");
foreach (var entry in combined)
{
var parts = entry.Split('');
var text = parts.Length > 0 ? parts[0] : "";
var href = parts.Length > 1 ? parts[1] : "";
// Require the link to point at an actual document, not a generic /documents nav page.
if (href.Length > 0
&& IsDocumentUrl(href)
&& patterns.Any(p => Regex.IsMatch(text, p, RegexOptions.IgnoreCase)))
return href;
}
return "";
}
/// <summary>True when an href looks like a real document (hosted on the NIC CDN or a direct PDF).</summary>
private static bool IsDocumentUrl(string href)
{
var path = href.Split('?')[0];
return href.Contains("nicindustries.com", StringComparison.OrdinalIgnoreCase)
|| path.EndsWith(".pdf", StringComparison.OrdinalIgnoreCase);
}
private async Task<string> GetSampleImageUrlAsync()
{
var srcs = await _session.Page.EvalOnSelectorAllAsync<string[]>(
"img",
"els => els.map(i => i.currentSrc || i.src || i.getAttribute('src') || i.getAttribute('data-src') || '')" +
".filter(Boolean)");
// Only accept real product images on the NIC CDN (prefer full-size over thumbnail). Do NOT
// fall back to any "prismatic"-ish URL — that catches the site logo on products with no image.
return srcs.FirstOrDefault(s => Regex.IsMatch(s, @"images\.nicindustries\.com/prismatic/products", RegexOptions.IgnoreCase)
&& !Regex.IsMatch(s, "thumbnail", RegexOptions.IgnoreCase))
?? srcs.FirstOrDefault(s => Regex.IsMatch(s, @"images\.nicindustries\.com/prismatic/products", RegexOptions.IgnoreCase))
?? "";
}
private static bool IsBlocked(Exception ex) =>
ex.Message.Contains("403", StringComparison.OrdinalIgnoreCase);
private static async Task<string> SafeTextAsync(Func<Task<string>> fn)
{
try { return await fn(); } catch { return ""; }
}
private static string Clean(string? text) => WhitespaceRegex.Replace(text ?? "", " ").Trim();
private int RandomDelayMs()
{
var min = Math.Max(0, _config.MinDelaySeconds * 1000);
var max = Math.Max(min, _config.MaxDelaySeconds * 1000);
return _random.Next(min, max + 1);
}
private static string FormatDuration(TimeSpan t) =>
t.TotalHours >= 1 ? $"{(int)t.TotalHours}h {t.Minutes}m" :
t.TotalMinutes >= 1 ? $"{(int)t.TotalMinutes}m {t.Seconds}s" :
$"{t.Seconds}s";
}
@@ -0,0 +1,38 @@
{
"Sync": {
"BaseUrl": "https://www.prismaticpowders.com",
"ColorsPath": "/shop/powder-coating-colors",
"ProductUrlsFile": "product-urls.txt",
"OutputJsonFile": "prismatic_powders.json",
"LogFile": "prismatic-sync.log",
"MinDelaySeconds": 6,
"MaxDelaySeconds": 14,
"PageSettleSeconds": 4,
"BlockedCooldownSeconds": 120,
"BlockedCooldownMaxSeconds": 600,
"BlockedMaxRetries": 3,
"LongRestEveryProducts": 150,
"LongRestSeconds": 45,
"ScrollWaitMs": 1500,
"MaxScrolls": 400,
"StopAfterNoNewScrolls": 10,
"StopAfterKnownScrolls": 8,
"ColorParams": [
"pris_black", "pris_blue", "pris_bronze", "pris_brown", "pris_clear",
"pris_copper", "pris_gold", "pris_gray", "pris_green", "pris_orange",
"pris_pink", "pris_purple", "pris_red", "pris_silver", "pris_tan",
"pris_white", "pris_yellow"
],
"Import": {
"EndpointUrl": "",
"Token": "",
"VendorName": "Prismatic Powders"
}
}
}
-130
View File
@@ -1,130 +0,0 @@
BEGIN TRANSACTION;
GO
IF NOT EXISTS (
SELECT * FROM [__EFMigrationsHistory]
WHERE [MigrationId] = N'20260620134918_AddCompanyDefaultGlAccounts'
)
BEGIN
ALTER TABLE [CompanyPreferences] ADD [DefaultCogsAccountId] int NULL;
END;
GO
IF NOT EXISTS (
SELECT * FROM [__EFMigrationsHistory]
WHERE [MigrationId] = N'20260620134918_AddCompanyDefaultGlAccounts'
)
BEGIN
ALTER TABLE [CompanyPreferences] ADD [DefaultInventoryAccountId] int NULL;
END;
GO
IF NOT EXISTS (
SELECT * FROM [__EFMigrationsHistory]
WHERE [MigrationId] = N'20260620134918_AddCompanyDefaultGlAccounts'
)
BEGIN
ALTER TABLE [CompanyPreferences] ADD [DefaultRevenueAccountId] int NULL;
END;
GO
IF NOT EXISTS (
SELECT * FROM [__EFMigrationsHistory]
WHERE [MigrationId] = N'20260620134918_AddCompanyDefaultGlAccounts'
)
BEGIN
EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-06-20T13:49:14.5644507Z''
WHERE [Id] = 1;
SELECT @@ROWCOUNT');
END;
GO
IF NOT EXISTS (
SELECT * FROM [__EFMigrationsHistory]
WHERE [MigrationId] = N'20260620134918_AddCompanyDefaultGlAccounts'
)
BEGIN
EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-06-20T13:49:14.5644514Z''
WHERE [Id] = 2;
SELECT @@ROWCOUNT');
END;
GO
IF NOT EXISTS (
SELECT * FROM [__EFMigrationsHistory]
WHERE [MigrationId] = N'20260620134918_AddCompanyDefaultGlAccounts'
)
BEGIN
EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-06-20T13:49:14.5644515Z''
WHERE [Id] = 3;
SELECT @@ROWCOUNT');
END;
GO
IF NOT EXISTS (
SELECT * FROM [__EFMigrationsHistory]
WHERE [MigrationId] = N'20260620134918_AddCompanyDefaultGlAccounts'
)
BEGIN
CREATE INDEX [IX_CompanyPreferences_DefaultCogsAccountId] ON [CompanyPreferences] ([DefaultCogsAccountId]);
END;
GO
IF NOT EXISTS (
SELECT * FROM [__EFMigrationsHistory]
WHERE [MigrationId] = N'20260620134918_AddCompanyDefaultGlAccounts'
)
BEGIN
CREATE INDEX [IX_CompanyPreferences_DefaultInventoryAccountId] ON [CompanyPreferences] ([DefaultInventoryAccountId]);
END;
GO
IF NOT EXISTS (
SELECT * FROM [__EFMigrationsHistory]
WHERE [MigrationId] = N'20260620134918_AddCompanyDefaultGlAccounts'
)
BEGIN
CREATE INDEX [IX_CompanyPreferences_DefaultRevenueAccountId] ON [CompanyPreferences] ([DefaultRevenueAccountId]);
END;
GO
IF NOT EXISTS (
SELECT * FROM [__EFMigrationsHistory]
WHERE [MigrationId] = N'20260620134918_AddCompanyDefaultGlAccounts'
)
BEGIN
ALTER TABLE [CompanyPreferences] ADD CONSTRAINT [FK_CompanyPreferences_Accounts_DefaultCogsAccountId] FOREIGN KEY ([DefaultCogsAccountId]) REFERENCES [Accounts] ([Id]);
END;
GO
IF NOT EXISTS (
SELECT * FROM [__EFMigrationsHistory]
WHERE [MigrationId] = N'20260620134918_AddCompanyDefaultGlAccounts'
)
BEGIN
ALTER TABLE [CompanyPreferences] ADD CONSTRAINT [FK_CompanyPreferences_Accounts_DefaultInventoryAccountId] FOREIGN KEY ([DefaultInventoryAccountId]) REFERENCES [Accounts] ([Id]);
END;
GO
IF NOT EXISTS (
SELECT * FROM [__EFMigrationsHistory]
WHERE [MigrationId] = N'20260620134918_AddCompanyDefaultGlAccounts'
)
BEGIN
ALTER TABLE [CompanyPreferences] ADD CONSTRAINT [FK_CompanyPreferences_Accounts_DefaultRevenueAccountId] FOREIGN KEY ([DefaultRevenueAccountId]) REFERENCES [Accounts] ([Id]);
END;
GO
IF NOT EXISTS (
SELECT * FROM [__EFMigrationsHistory]
WHERE [MigrationId] = N'20260620134918_AddCompanyDefaultGlAccounts'
)
BEGIN
INSERT INTO [__EFMigrationsHistory] ([MigrationId], [ProductVersion])
VALUES (N'20260620134918_AddCompanyDefaultGlAccounts', N'8.0.11');
END;
GO
COMMIT;
GO
@@ -154,49 +154,6 @@ public class TrialBalanceLine
public decimal CreditBalance { get; set; }
}
// ── Balance Reconciliation ─────────────────────────────────────────────────────
/// <summary>
/// Diagnostic that surfaces drift in the denormalized balances: each account's stored
/// <c>CurrentBalance</c> vs its recomputed ledger balance, plus the AR/AP subledger totals
/// (sum of Customer/Vendor CurrentBalance) vs their GL control account balances. Read-only.
/// </summary>
public class BalanceReconciliationDto
{
public DateTime AsOf { get; set; }
public string CompanyName { get; set; } = string.Empty;
public List<BalanceReconciliationLine> AccountLines { get; set; } = new();
public decimal ArControlBalance { get; set; }
public decimal ArSubledgerTotal { get; set; }
public decimal ArDifference => ArControlBalance - ArSubledgerTotal;
public decimal ApControlBalance { get; set; }
public decimal ApSubledgerTotal { get; set; }
public decimal ApDifference => ApControlBalance - ApSubledgerTotal;
public IEnumerable<BalanceReconciliationLine> DriftedAccounts => AccountLines.Where(l => !l.IsReconciled);
public bool AccountsReconciled => AccountLines.All(l => l.IsReconciled);
public bool ArReconciled => Math.Abs(ArDifference) < 0.01m;
public bool ApReconciled => Math.Abs(ApDifference) < 0.01m;
public bool AllReconciled => AccountsReconciled && ArReconciled && ApReconciled;
}
public class BalanceReconciliationLine
{
public int AccountId { get; set; }
public string AccountNumber { get; set; } = string.Empty;
public string AccountName { get; set; } = string.Empty;
public AccountType AccountType { get; set; }
/// <summary>The denormalized Account.CurrentBalance (what most UI reads).</summary>
public decimal StoredBalance { get; set; }
/// <summary>The balance recomputed from source documents (what RecalculateBalances would set).</summary>
public decimal LedgerBalance { get; set; }
public decimal Difference => StoredBalance - LedgerBalance;
public bool IsReconciled => Math.Abs(Difference) < 0.01m;
}
// ── Profit & Loss ─────────────────────────────────────────────────────────────
public class ProfitAndLossDto
@@ -1,55 +0,0 @@
using CsvHelper.Configuration.Attributes;
namespace PowderCoating.Application.DTOs.Import;
/// <summary>
/// DTO for importing vendor bill headers from CSV. Column names match the native bills export
/// (ExportBillsCsv) for round-trip compatibility. The vendor is resolved by name and the AP account
/// by number so accounting linkages survive. Line items import separately via BillLineItemImportDto.
/// </summary>
public class BillImportDto
{
[Name("BillNumber")]
public string? BillNumber { get; set; }
[Name("VendorInvoiceNumber")]
public string? VendorInvoiceNumber { get; set; }
/// <summary>Vendor company name, matched against Vendor.CompanyName.</summary>
[Name("VendorName")]
public string? VendorName { get; set; }
/// <summary>AP account number (Chart of Accounts) this bill posts to.</summary>
[Name("APAccountNumber")]
public string? APAccountNumber { get; set; }
[Name("BillDate")]
public DateTime BillDate { get; set; }
[Name("DueDate")]
public DateTime? DueDate { get; set; }
[Name("Status")]
public string Status { get; set; } = "Open";
[Name("Terms")]
public string? Terms { get; set; }
[Name("Memo")]
public string? Memo { get; set; }
[Name("SubTotal")]
public decimal SubTotal { get; set; }
[Name("TaxPercent")]
public decimal TaxPercent { get; set; }
[Name("TaxAmount")]
public decimal TaxAmount { get; set; }
[Name("Total")]
public decimal Total { get; set; }
[Name("AmountPaid")]
public decimal AmountPaid { get; set; }
}
@@ -1,37 +0,0 @@
using CsvHelper.Configuration.Attributes;
namespace PowderCoating.Application.DTOs.Import;
/// <summary>
/// DTO for importing vendor bill line items from CSV. Column names match the native bill-items export
/// (ExportBillLineItemsCsv). Lines are matched to their parent bill by BillNumber; the expense/asset
/// account is resolved (optional) from AccountNumber so each line's GL attribution round-trips.
/// </summary>
public class BillLineItemImportDto
{
[Name("BillNumber")]
public string? BillNumber { get; set; }
/// <summary>Expense/asset account number this line is categorized under. Optional.</summary>
[Name("AccountNumber")]
public string? AccountNumber { get; set; }
/// <summary>Optional job-costing link, matched against Job.JobNumber.</summary>
[Name("JobNumber")]
public string? JobNumber { get; set; }
[Name("Description")]
public string? Description { get; set; }
[Name("Quantity")]
public decimal Quantity { get; set; }
[Name("UnitPrice")]
public decimal UnitPrice { get; set; }
[Name("Amount")]
public decimal Amount { get; set; }
[Name("DisplayOrder")]
public int DisplayOrder { get; set; }
}
@@ -1,46 +0,0 @@
using CsvHelper.Configuration.Attributes;
namespace PowderCoating.Application.DTOs.Import;
/// <summary>
/// DTO for importing customer deposits from CSV. Column names match the native deposits export
/// (ExportDepositsCsv). The customer is resolved by name, the bank account by number
/// (DepositAccountNumber), and the optional applied invoice by number so the deposit's linkages
/// survive an export/import round-trip.
/// </summary>
public class DepositImportDto
{
[Name("ReceiptNumber")]
public string? ReceiptNumber { get; set; }
/// <summary>Customer name (company name, or contact full name), matched against the customer record.</summary>
[Name("CustomerName")]
public string? CustomerName { get; set; }
[Name("Amount")]
public decimal Amount { get; set; }
/// <summary>Valid values: Cash, Check, CreditDebitCard, BankTransferACH, DigitalPayment</summary>
[Name("PaymentMethod")]
public string PaymentMethod { get; set; } = "Cash";
[Name("ReceivedDate")]
public DateTime ReceivedDate { get; set; }
/// <summary>Bank/cash account number (Chart of Accounts) the deposit landed in. Optional.</summary>
[Name("DepositAccountNumber")]
public string? DepositAccountNumber { get; set; }
/// <summary>Invoice number this deposit has been applied to, if any. Optional.</summary>
[Name("AppliedToInvoiceNumber")]
public string? AppliedToInvoiceNumber { get; set; }
[Name("AppliedDate")]
public DateTime? AppliedDate { get; set; }
[Name("Reference")]
public string? Reference { get; set; }
[Name("Notes")]
public string? Notes { get; set; }
}
@@ -1,44 +0,0 @@
using CsvHelper.Configuration.Attributes;
namespace PowderCoating.Application.DTOs.Import;
/// <summary>
/// DTO for importing invoice line items from CSV. Column names match the native
/// invoice-items export (ExportInvoiceItemsCsv) for round-trip compatibility.
/// Line items are matched to their parent invoice by <c>InvoiceNumber</c>; the revenue
/// account is resolved from <c>RevenueAccountNumber</c> against Account.AccountNumber so the
/// invoice's revenue attribution survives an export/import round-trip.
/// </summary>
public class InvoiceItemImportDto
{
[Name("InvoiceNumber")]
public string? InvoiceNumber { get; set; }
[Name("Description")]
public string? Description { get; set; }
[Name("Quantity")]
public decimal Quantity { get; set; }
[Name("UnitPrice")]
public decimal UnitPrice { get; set; }
[Name("TotalPrice")]
public decimal TotalPrice { get; set; }
[Name("ColorName")]
public string? ColorName { get; set; }
/// <summary>
/// Account number (Chart of Accounts) of the revenue account this line posts to. Optional —
/// a blank value means the line falls back to the company's default revenue account.
/// </summary>
[Name("RevenueAccountNumber")]
public string? RevenueAccountNumber { get; set; }
[Name("DisplayOrder")]
public int DisplayOrder { get; set; }
[Name("Notes")]
public string? Notes { get; set; }
}
@@ -1,27 +0,0 @@
using CsvHelper.Configuration.Attributes;
namespace PowderCoating.Application.DTOs.Import;
/// <summary>
/// DTO for importing journal entry headers from CSV. Column names match the native journal-entries
/// export (ExportJournalEntriesCsv). The debit/credit lines import separately via
/// JournalEntryLineImportDto and must balance per entry.
/// </summary>
public class JournalEntryImportDto
{
[Name("EntryNumber")]
public string? EntryNumber { get; set; }
[Name("EntryDate")]
public DateTime EntryDate { get; set; }
[Name("Reference")]
public string? Reference { get; set; }
[Name("Description")]
public string? Description { get; set; }
/// <summary>Valid values: Draft, Posted, Reversed</summary>
[Name("Status")]
public string Status { get; set; } = "Draft";
}
@@ -1,31 +0,0 @@
using CsvHelper.Configuration.Attributes;
namespace PowderCoating.Application.DTOs.Import;
/// <summary>
/// DTO for importing journal entry lines from CSV. Column names match the native journal-entry-lines
/// export (ExportJournalEntryLinesCsv). Lines are matched to their parent entry by EntryNumber and the
/// account is resolved from AccountNumber (required — a JE line is meaningless without its account).
/// Either DebitAmount or CreditAmount is non-zero per line, not both.
/// </summary>
public class JournalEntryLineImportDto
{
[Name("EntryNumber")]
public string? EntryNumber { get; set; }
/// <summary>Account number (Chart of Accounts) this line debits or credits. Required.</summary>
[Name("AccountNumber")]
public string? AccountNumber { get; set; }
[Name("DebitAmount")]
public decimal DebitAmount { get; set; }
[Name("CreditAmount")]
public decimal CreditAmount { get; set; }
[Name("Description")]
public string? Description { get; set; }
[Name("LineOrder")]
public int LineOrder { get; set; }
}
@@ -24,14 +24,6 @@ public class PaymentImportDto
[Name("PaymentMethod")]
public string PaymentMethod { get; set; } = "Cash";
/// <summary>
/// Account number (Chart of Accounts) of the bank/cash account the payment was deposited into.
/// Resolved back to <c>DepositAccountId</c> on import so the balance recalc can post it to the
/// right bank account. Optional — a blank value means no deposit account was recorded.
/// </summary>
[Name("DepositAccountNumber")]
public string? DepositAccountNumber { get; set; }
[Name("Reference")]
public string? Reference { get; set; }
@@ -225,12 +225,6 @@ public class CreateInventoryItemDto
[Display(Name = "Incoming / On Order")]
public bool IsIncoming { get; set; }
/// <summary>
/// Existing inventory record the user explicitly chose to bypass when creating a separate
/// powder lot or location. SKU duplicates can never be bypassed.
/// </summary>
public int? DuplicateOverrideInventoryItemId { get; set; }
}
public class UpdateInventoryItemDto : CreateInventoryItemDto
@@ -179,36 +179,10 @@ public interface ICsvImportService
/// <summary>
/// Import invoice headers from a CSV stream. Customers are resolved by CustomerEmail then
/// CustomerName. Duplicate detection uses InvoiceNumber as the unique key. Existing invoices
/// are updated; new ones are created. Line items are imported separately via
/// <see cref="ImportInvoiceItemsAsync"/>.
/// are updated; new ones are created. Line items are not part of the CSV format.
/// </summary>
Task<CsvImportResultDto> ImportInvoicesAsync(Stream csvStream, int companyId);
/// <summary>
/// Import invoice line items from a CSV stream. Each line is matched to its parent invoice by
/// InvoiceNumber and its revenue account resolved (optional) from RevenueAccountNumber. Idempotent
/// by description + total + display order. Run after invoices have been imported.
/// </summary>
Task<CsvImportResultDto> ImportInvoiceItemsAsync(Stream csvStream, int companyId);
/// <summary>Import vendor bill headers. Vendor by name, AP account by number. Dedup by BillNumber.</summary>
Task<CsvImportResultDto> ImportBillsAsync(Stream csvStream, int companyId);
/// <summary>Import vendor bill line items. Matched to bills by BillNumber; account/job by number.</summary>
Task<CsvImportResultDto> ImportBillLineItemsAsync(Stream csvStream, int companyId);
/// <summary>Import customer deposits. Customer by name, bank account by number, applied invoice by number.</summary>
Task<CsvImportResultDto> ImportDepositsAsync(Stream csvStream, int companyId);
/// <summary>Import journal entry headers. Dedup by EntryNumber. Lines import separately.</summary>
Task<CsvImportResultDto> ImportJournalEntriesAsync(Stream csvStream, int companyId);
/// <summary>Import journal entry lines. Matched to entries by EntryNumber; account by number (required).</summary>
Task<CsvImportResultDto> ImportJournalEntryLinesAsync(Stream csvStream, int companyId);
/// <summary>Generate a CSV template file for invoice line-item imports.</summary>
byte[] GenerateInvoiceItemTemplate();
/// <summary>Generate a CSV template file for payment imports.</summary>
byte[] GeneratePaymentTemplate();
@@ -33,12 +33,6 @@ public interface IFinancialReportService
/// <summary>Returns a Trial Balance using current account balances as of the given date.</summary>
Task<TrialBalanceDto> GetTrialBalanceAsync(int companyId, DateTime asOf);
/// <summary>
/// Returns a balance reconciliation: each account's stored CurrentBalance vs its recomputed ledger
/// balance, plus AR/AP subledger totals vs their control accounts. Read-only drift diagnostic.
/// </summary>
Task<BalanceReconciliationDto> GetBalanceReconciliationAsync(int companyId);
/// <summary>Looks up the accounting method configured for the given company. Returns Accrual if not found.</summary>
Task<AccountingMethod> GetCompanyAccountingMethodAsync(int companyId);
@@ -1,91 +0,0 @@
using PowderCoating.Core.Entities;
namespace PowderCoating.Application.Services;
public enum InventoryDuplicateMatchType
{
Sku,
ManufacturerPartNumber,
ManufacturerColor
}
public sealed record InventoryDuplicateMatch(
InventoryItem Item,
InventoryDuplicateMatchType MatchType);
/// <summary>
/// Shared inventory duplicate rules used by manual creation and powder-label scanning.
/// Callers are responsible for supplying inventory already restricted to the current tenant.
/// </summary>
public static class InventoryDuplicateMatcher
{
public static InventoryDuplicateMatch? Find(
IEnumerable<InventoryItem> inventoryItems,
int companyId,
string? sku,
string? manufacturer,
string? manufacturerPartNumber,
string? colorName,
bool isCoating,
int? excludeId = null)
{
var candidates = inventoryItems
.Where(i => i.CompanyId == companyId && i.Id != excludeId)
.ToList();
var normalizedSku = Normalize(sku);
if (normalizedSku.Length > 0)
{
var skuMatch = candidates.FirstOrDefault(i => Normalize(i.SKU) == normalizedSku);
if (skuMatch != null)
return new InventoryDuplicateMatch(skuMatch, InventoryDuplicateMatchType.Sku);
}
if (!isCoating)
return null;
var coatingCandidates = candidates
.Where(i => i.InventoryCategory?.IsCoating == true)
.ToList();
var normalizedManufacturer = Normalize(manufacturer);
var normalizedPartNumber = Normalize(manufacturerPartNumber);
if (normalizedPartNumber.Length > 0)
{
var partNumberMatch = coatingCandidates.FirstOrDefault(i =>
Normalize(i.ManufacturerPartNumber) == normalizedPartNumber &&
(normalizedManufacturer.Length == 0 ||
Normalize(i.Manufacturer) == normalizedManufacturer));
if (partNumberMatch != null)
return new InventoryDuplicateMatch(
partNumberMatch,
InventoryDuplicateMatchType.ManufacturerPartNumber);
}
var normalizedColorName = Normalize(colorName);
if (normalizedManufacturer.Length == 0 || normalizedColorName.Length == 0)
return null;
var manufacturerColorMatch = coatingCandidates.FirstOrDefault(i =>
Normalize(i.Manufacturer) == normalizedManufacturer &&
Normalize(i.ColorName ?? i.Name) == normalizedColorName);
return manufacturerColorMatch == null
? null
: new InventoryDuplicateMatch(
manufacturerColorMatch,
InventoryDuplicateMatchType.ManufacturerColor);
}
private static string Normalize(string? value)
{
if (string.IsNullOrWhiteSpace(value))
return string.Empty;
return string.Join(
' ',
value.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries))
.ToUpperInvariant();
}
}
@@ -1,29 +0,0 @@
namespace PowderCoating.Core.Accounting;
/// <summary>
/// Single source of truth for splitting a customer refund into its revenue (returns) portion and
/// its sales-tax portion, under the "reverse the sale" model. A refund of a paid invoice reverses
/// the original sale: the revenue portion is debited to Sales Returns (contra-revenue) and the tax
/// portion is debited to Sales Tax Payable (reducing the liability), with cash credited out.
///
/// The split is proportional to the parent invoice's tax ratio so a partial refund reverses the
/// right amount of tax. Centralised here so the posting (<c>InvoicesController</c>) and the two
/// reporting recomputes (<c>LedgerService</c>, <c>FinancialReportService</c>) always agree — if they
/// computed it independently the trial balance could drift.
/// </summary>
public static class RefundAllocation
{
/// <summary>
/// Splits <paramref name="refundAmount"/> (tax-inclusive) into (returnsPortion, taxPortion) using
/// the parent invoice's tax ratio. When the invoice has no total or no tax, the whole refund is
/// the returns portion and the tax portion is zero.
/// </summary>
public static (decimal ReturnsPortion, decimal TaxPortion) Split(
decimal refundAmount, decimal invoiceTaxAmount, decimal invoiceTotal)
{
var taxPortion = invoiceTotal > 0m && invoiceTaxAmount > 0m
? Math.Round(refundAmount * invoiceTaxAmount / invoiceTotal, 2, MidpointRounding.AwayFromZero)
: 0m;
return (refundAmount - taxPortion, taxPortion);
}
}
@@ -18,18 +18,6 @@ public class CompanyPreferences : BaseEntity
public string InvoiceNumberPrefix { get; set; } = "INV";
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
public string DefaultJobPriority { get; set; } = "Normal";
public bool RequireCustomerPO { get; set; } = false;
@@ -1,48 +0,0 @@
namespace PowderCoating.Core.Enums;
/// <summary>
/// Single source of truth mapping an <see cref="AccountSubType"/> to its parent
/// <see cref="AccountType"/>. Each sub-type belongs to exactly one type, so the type can always
/// be derived from the sub-type. Used on account create/edit to keep the two fields consistent
/// (a mismatched pair would post with the wrong debit/credit sign, since the sign convention keys
/// off the sub-type) and anywhere else that needs the canonical pairing.
/// </summary>
public static class AccountClassification
{
/// <summary>Returns the parent <see cref="AccountType"/> for a given <see cref="AccountSubType"/>.</summary>
public static AccountType TypeForSubType(AccountSubType subType) => subType switch
{
AccountSubType.Cash or AccountSubType.Checking or AccountSubType.Savings
or AccountSubType.AccountsReceivable or AccountSubType.Inventory or AccountSubType.FixedAsset
or AccountSubType.OtherCurrentAsset or AccountSubType.OtherAsset => AccountType.Asset,
AccountSubType.AccountsPayable or AccountSubType.CreditCard
or AccountSubType.OtherCurrentLiability or AccountSubType.LongTermLiability => AccountType.Liability,
AccountSubType.OwnersEquity or AccountSubType.RetainedEarnings => AccountType.Equity,
AccountSubType.Sales or AccountSubType.ServiceRevenue or AccountSubType.OtherIncome => AccountType.Revenue,
AccountSubType.CostOfGoodsSold => AccountType.CostOfGoods,
// All expense sub-types (enum values >= 50) and any future additions default to Expense.
_ => AccountType.Expense
};
/// <summary>
/// Returns a sensible generic <see cref="AccountSubType"/> for a given <see cref="AccountType"/>.
/// 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 <c>Other</c> (an expense-range sub-type)
/// and post with the wrong debit/credit sign, since the sign convention keys off sub-type.
/// </summary>
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
};
}
@@ -946,26 +946,6 @@ modelBuilder.Entity<ReworkRecord>().HasQueryFilter(e =>
.HasForeignKey(i => i.CogsAccountId)
.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)
modelBuilder.Entity<CatalogItem>()
.HasOne(ci => ci.RevenueAccount)
@@ -1359,12 +1359,8 @@ New accounts walk through an 18-step setup wizard to configure company informati
new() { AccountNumber = "2000", Name = "Accounts Payable", AccountType = AccountType.Liability, AccountSubType = AccountSubType.AccountsPayable, IsSystem = true, IsActive = true, CompanyId = company.Id, CreatedAt = now },
new() { AccountNumber = "2100", Name = "Credit Card", AccountType = AccountType.Liability, AccountSubType = AccountSubType.CreditCard, IsSystem = true, IsActive = true, CompanyId = company.Id, CreatedAt = now },
new() { AccountNumber = "2200", Name = "Sales Tax Payable", AccountType = AccountType.Liability, AccountSubType = AccountSubType.OtherCurrentLiability, IsSystem = false, IsActive = true, CompanyId = company.Id, CreatedAt = now },
// 2300 = Customer Deposits liability (resolved by number in the deposit GL posting code); payroll is at 2400.
new() { AccountNumber = "2300", Name = "Customer Deposits", AccountType = AccountType.Liability, AccountSubType = AccountSubType.OtherCurrentLiability, IsSystem = true, IsActive = true, CompanyId = company.Id, CreatedAt = now },
new() { AccountNumber = "2400", Name = "Payroll Liabilities", AccountType = AccountType.Liability, AccountSubType = AccountSubType.OtherCurrentLiability, IsSystem = false, IsActive = true, CompanyId = company.Id, CreatedAt = now },
// 2500 = Gift Certificate Liability (resolved by number in the GC GL posting code); long-term loan moved to 2900.
new() { AccountNumber = "2500", Name = "Gift Certificate Liability", AccountType = AccountType.Liability, AccountSubType = AccountSubType.OtherCurrentLiability, IsSystem = true, IsActive = true, CompanyId = company.Id, CreatedAt = now },
new() { AccountNumber = "2900", Name = "Long-Term Loan", AccountType = AccountType.Liability, AccountSubType = AccountSubType.LongTermLiability, IsSystem = false, IsActive = true, CompanyId = company.Id, CreatedAt = now },
new() { AccountNumber = "2300", Name = "Payroll Liabilities", AccountType = AccountType.Liability, AccountSubType = AccountSubType.OtherCurrentLiability, IsSystem = false, IsActive = true, CompanyId = company.Id, CreatedAt = now },
new() { AccountNumber = "2500", Name = "Long-Term Loan", AccountType = AccountType.Liability, AccountSubType = AccountSubType.LongTermLiability, IsSystem = false, IsActive = true, CompanyId = company.Id, CreatedAt = now },
// ── Equity ──────────────────────────────────────────────────────
new() { AccountNumber = "3000", Name = "Owner's Equity", AccountType = AccountType.Equity, AccountSubType = AccountSubType.OwnersEquity, IsSystem = true, IsActive = true, CompanyId = company.Id, CreatedAt = now },
@@ -1,119 +0,0 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace PowderCoating.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class RenameDepositsAccountAddPayroll : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
// O1 remediation. Account 2300 has always been used by the deposit GL posting code as the
// Customer Deposits liability (resolved by number), but pre-migration tenants still had it
// seeded/named "Payroll Liabilities" — so the liability was mislabeled on the balance sheet.
// Rename it to "Customer Deposits" and mark it system. Only touch accounts still carrying the
// old default name, so a tenant's own rename is preserved.
migrationBuilder.Sql(@"
UPDATE Accounts
SET Name = 'Customer Deposits',
Description = 'Deposits received from customers before an invoice is created; cleared when applied to an invoice',
IsSystem = 1
WHERE AccountNumber = '2300'
AND IsDeleted = 0
AND Name = 'Payroll Liabilities';
");
// Re-home payroll to a dedicated 2400 account for every company that lacks one, so the chart
// still offers a payroll liability without colliding with Customer Deposits at 2300.
migrationBuilder.Sql(@"
INSERT INTO Accounts
(AccountNumber, Name, AccountType, AccountSubType,
IsSystem, IsActive, Description,
CompanyId, CreatedAt, IsDeleted,
CurrentBalance, OpeningBalance)
SELECT
'2400',
'Payroll Liabilities',
2, -- AccountType.Liability
12, -- AccountSubType.OtherCurrentLiability
0, -- IsSystem = false
1, -- IsActive = true
'Payroll taxes and withholdings owed',
c.Id,
GETUTCDATE(),
0, -- IsDeleted = false
0, -- CurrentBalance
0 -- OpeningBalance
FROM Companies c
WHERE c.IsDeleted = 0
AND NOT EXISTS (
SELECT 1 FROM Accounts a
WHERE a.CompanyId = c.Id
AND a.AccountNumber = '2400'
AND a.IsDeleted = 0
);
");
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 1,
column: "CreatedAt",
value: new DateTime(2026, 6, 19, 23, 31, 4, 905, DateTimeKind.Utc).AddTicks(7611));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 2,
column: "CreatedAt",
value: new DateTime(2026, 6, 19, 23, 31, 4, 905, DateTimeKind.Utc).AddTicks(7618));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 3,
column: "CreatedAt",
value: new DateTime(2026, 6, 19, 23, 31, 4, 905, DateTimeKind.Utc).AddTicks(7619));
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
// Best-effort reversal. The 2300 rename is intentionally NOT undone: it corrected a mislabeled
// account and reverting would re-introduce the bug. Only the empty 2400 accounts this migration
// added are soft-deleted (skip any that already carry a balance, i.e. are in use).
migrationBuilder.Sql(@"
UPDATE Accounts
SET IsDeleted = 1
WHERE AccountNumber = '2400'
AND IsDeleted = 0
AND Name = 'Payroll Liabilities'
AND CurrentBalance = 0;
");
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 1,
column: "CreatedAt",
value: new DateTime(2026, 6, 17, 19, 26, 43, 161, DateTimeKind.Utc).AddTicks(2051));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 2,
column: "CreatedAt",
value: new DateTime(2026, 6, 17, 19, 26, 43, 161, DateTimeKind.Utc).AddTicks(2056));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 3,
column: "CreatedAt",
value: new DateTime(2026, 6, 17, 19, 26, 43, 161, DateTimeKind.Utc).AddTicks(2057));
}
}
}
@@ -1,123 +0,0 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace PowderCoating.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class FixGiftCertificateLiabilityAccount : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
// O5 remediation. Account 2500 is resolved by number as the Gift Certificate Liability
// (GiftCertificatesController), but the default-company chart seeded it as "Long-Term Loan",
// so GC obligations were mislabeled there and the AccountingGapsPhase2 GC-liability seed was
// skipped by its NOT EXISTS guard.
// 1) Preserve the long-term loan account: move it to 2900 for any company whose 2500 is still
// named "Long-Term Loan" and that lacks a 2900. (Companies onboarded via the per-tenant
// seeder already have a 2900 "Business Loan", so the NOT EXISTS guard leaves them alone.)
migrationBuilder.Sql(@"
INSERT INTO Accounts
(AccountNumber, Name, AccountType, AccountSubType,
IsSystem, IsActive, Description,
CompanyId, CreatedAt, IsDeleted, CurrentBalance, OpeningBalance)
SELECT '2900', 'Long-Term Loan',
2, -- AccountType.Liability
11, -- AccountSubType.LongTermLiability
0, 1, 'Long-term equipment or business loan',
c.Id, GETUTCDATE(), 0, 0, 0
FROM Companies c
WHERE c.IsDeleted = 0
AND EXISTS (SELECT 1 FROM Accounts a WHERE a.CompanyId = c.Id
AND a.AccountNumber = '2500' AND a.IsDeleted = 0 AND a.Name = 'Long-Term Loan')
AND NOT EXISTS (SELECT 1 FROM Accounts a WHERE a.CompanyId = c.Id
AND a.AccountNumber = '2900' AND a.IsDeleted = 0);
");
// 2) Relabel the mislabeled 2500 to Gift Certificate Liability (only where it still carries the
// old default name, so a user's own rename is preserved). Id / number / balance untouched.
migrationBuilder.Sql(@"
UPDATE Accounts
SET Name = 'Gift Certificate Liability',
Description = 'Outstanding gift certificate obligations owed to certificate holders',
IsSystem = 1
WHERE AccountNumber = '2500' AND IsDeleted = 0 AND Name = 'Long-Term Loan';
");
// 3) Safety net: ensure every company has a 2500 Gift Certificate Liability (covers any tenant
// onboarded after AccountingGapsPhase2 ran that never received one — without it GC GL no-ops).
migrationBuilder.Sql(@"
INSERT INTO Accounts
(AccountNumber, Name, AccountType, AccountSubType,
IsSystem, IsActive, Description,
CompanyId, CreatedAt, IsDeleted, CurrentBalance, OpeningBalance)
SELECT '2500', 'Gift Certificate Liability',
2, -- AccountType.Liability
12, -- AccountSubType.OtherCurrentLiability
1, 1, 'Outstanding gift certificate obligations owed to certificate holders',
c.Id, GETUTCDATE(), 0, 0, 0
FROM Companies c
WHERE c.IsDeleted = 0
AND NOT EXISTS (SELECT 1 FROM Accounts a WHERE a.CompanyId = c.Id
AND a.AccountNumber = '2500' AND a.IsDeleted = 0);
");
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));
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
// Best-effort reversal: the 2500 relabel is intentionally NOT undone (reverting would
// re-introduce the mislabel), and 2500 rows are left in place since most pre-date this
// migration. Only soft-delete the empty 2900 accounts this migration added.
migrationBuilder.Sql(@"
UPDATE Accounts SET IsDeleted = 1
WHERE AccountNumber = '2900' AND IsDeleted = 0 AND Name = 'Long-Term Loan' AND CurrentBalance = 0;
");
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 1,
column: "CreatedAt",
value: new DateTime(2026, 6, 19, 23, 31, 4, 905, DateTimeKind.Utc).AddTicks(7611));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 2,
column: "CreatedAt",
value: new DateTime(2026, 6, 19, 23, 31, 4, 905, DateTimeKind.Utc).AddTicks(7618));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 3,
column: "CreatedAt",
value: new DateTime(2026, 6, 19, 23, 31, 4, 905, DateTimeKind.Utc).AddTicks(7619));
}
}
}
@@ -1,151 +0,0 @@
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,9 +2185,6 @@ namespace PowderCoating.Infrastructure.Migrations
b.Property<string>("CreatedBy")
.HasColumnType("nvarchar(max)");
b.Property<int?>("DefaultCogsAccountId")
.HasColumnType("int");
b.Property<string>("DefaultCurrency")
.IsRequired()
.HasColumnType("nvarchar(max)");
@@ -2196,9 +2193,6 @@ namespace PowderCoating.Infrastructure.Migrations
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<int?>("DefaultInventoryAccountId")
.HasColumnType("int");
b.Property<string>("DefaultJobPriority")
.IsRequired()
.HasColumnType("nvarchar(max)");
@@ -2210,9 +2204,6 @@ namespace PowderCoating.Infrastructure.Migrations
b.Property<int>("DefaultQuoteValidityDays")
.HasColumnType("int");
b.Property<int?>("DefaultRevenueAccountId")
.HasColumnType("int");
b.Property<string>("DefaultTimeFormat")
.IsRequired()
.HasColumnType("nvarchar(max)");
@@ -2389,12 +2380,6 @@ namespace PowderCoating.Infrastructure.Migrations
b.HasIndex("CompanyId")
.IsUnique();
b.HasIndex("DefaultCogsAccountId");
b.HasIndex("DefaultInventoryAccountId");
b.HasIndex("DefaultRevenueAccountId");
b.ToTable("CompanyPreferences");
});
@@ -7256,7 +7241,7 @@ namespace PowderCoating.Infrastructure.Migrations
{
Id = 1,
CompanyId = 0,
CreatedAt = new DateTime(2026, 6, 20, 13, 49, 14, 564, DateTimeKind.Utc).AddTicks(4507),
CreatedAt = new DateTime(2026, 6, 17, 19, 26, 43, 161, DateTimeKind.Utc).AddTicks(2051),
Description = "Standard pricing for regular customers",
DiscountPercent = 0m,
IsActive = true,
@@ -7267,7 +7252,7 @@ namespace PowderCoating.Infrastructure.Migrations
{
Id = 2,
CompanyId = 0,
CreatedAt = new DateTime(2026, 6, 20, 13, 49, 14, 564, DateTimeKind.Utc).AddTicks(4514),
CreatedAt = new DateTime(2026, 6, 17, 19, 26, 43, 161, DateTimeKind.Utc).AddTicks(2056),
Description = "5% discount for preferred customers",
DiscountPercent = 5m,
IsActive = true,
@@ -7278,7 +7263,7 @@ namespace PowderCoating.Infrastructure.Migrations
{
Id = 3,
CompanyId = 0,
CreatedAt = new DateTime(2026, 6, 20, 13, 49, 14, 564, DateTimeKind.Utc).AddTicks(4515),
CreatedAt = new DateTime(2026, 6, 17, 19, 26, 43, 161, DateTimeKind.Utc).AddTicks(2057),
Description = "10% discount for premium customers",
DiscountPercent = 10m,
IsActive = true,
@@ -9595,21 +9580,6 @@ namespace PowderCoating.Infrastructure.Migrations
.OnDelete(DeleteBehavior.Cascade)
.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");
});
@@ -2866,10 +2866,6 @@ 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))
@@ -3212,33 +3208,6 @@ public class CsvImportService : ICsvImportService
}
}
public byte[] GenerateInvoiceItemTemplate()
{
using var memoryStream = new MemoryStream();
using var writer = new StreamWriter(memoryStream);
using var csv = new CsvWriter(writer, new CsvConfiguration(CultureInfo.InvariantCulture));
csv.WriteHeader<InvoiceItemImportDto>();
csv.NextRecord();
csv.WriteRecord(new InvoiceItemImportDto
{
InvoiceNumber = "INV-2601-0001",
Description = "Powder coating - 4 wheels",
Quantity = 4,
UnitPrice = 75.00m,
TotalPrice = 300.00m,
ColorName = "Gloss Black",
RevenueAccountNumber = "47905",
DisplayOrder = 0,
Notes = ""
});
csv.NextRecord();
writer.Flush();
return memoryStream.ToArray();
}
public byte[] GeneratePaymentTemplate()
{
using var memoryStream = new MemoryStream();
@@ -3250,13 +3219,12 @@ public class CsvImportService : ICsvImportService
csv.WriteRecord(new PaymentImportDto
{
InvoiceNumber = "INV-2601-0001",
Amount = 250.00m,
PaymentDate = DateTime.Today,
PaymentMethod = "Check",
DepositAccountNumber = "10100",
Reference = "CHK-1234",
Notes = ""
InvoiceNumber = "INV-2601-0001",
Amount = 250.00m,
PaymentDate = DateTime.Today,
PaymentMethod = "Check",
Reference = "CHK-1234",
Notes = ""
});
csv.NextRecord();
@@ -3264,651 +3232,6 @@ public class CsvImportService : ICsvImportService
return memoryStream.ToArray();
}
/// <summary>
/// Imports invoice line items from CSV. Each row is matched to its parent invoice by InvoiceNumber;
/// the revenue account is resolved (optional) from RevenueAccountNumber against the Chart of Accounts.
/// Idempotent — an invoice line with the same description + total + display order is skipped, so the
/// import can be safely re-run. Run AFTER invoices have been imported (the parents must exist).
/// </summary>
public async Task<CsvImportResultDto> ImportInvoiceItemsAsync(Stream csvStream, int companyId)
{
var result = new CsvImportResultDto();
var rowNumber = 0;
try
{
using var reader = new StreamReader(csvStream);
using var csv = new CsvReader(reader, new CsvConfiguration(CultureInfo.InvariantCulture)
{
HeaderValidated = null,
MissingFieldFound = null
});
var records = csv.GetRecords<InvoiceItemImportDto>().ToList();
result.TotalRows = records.Count;
_logger.LogInformation("Starting import of {Count} invoice line items for company {CompanyId}", records.Count, companyId);
var invoices = await _unitOfWork.Invoices.GetAllAsync(false, i => i.InvoiceItems);
var invoiceByNumber = invoices.Where(i => !string.IsNullOrEmpty(i.InvoiceNumber))
.ToDictionary(i => i.InvoiceNumber.Trim(), i => i, StringComparer.OrdinalIgnoreCase);
var accounts = await _unitOfWork.Accounts.GetAllAsync();
var accountByNumber = accounts
.Where(a => !string.IsNullOrEmpty(a.AccountNumber))
.GroupBy(a => a.AccountNumber.Trim())
.ToDictionary(g => g.Key, g => g.First(), StringComparer.OrdinalIgnoreCase);
foreach (var record in records)
{
rowNumber++;
try
{
if (string.IsNullOrWhiteSpace(record.InvoiceNumber))
{
result.Errors.Add($"Row {rowNumber}: InvoiceNumber is required.");
result.ErrorCount++;
continue;
}
if (!invoiceByNumber.TryGetValue(record.InvoiceNumber.Trim(), out var invoice))
{
result.Errors.Add($"Row {rowNumber}: Invoice '{record.InvoiceNumber}' not found.");
result.ErrorCount++;
continue;
}
var description = StripQuotes(record.Description)?.Trim() ?? "";
// Idempotency: skip a line that already exists on this invoice.
var isDuplicate = invoice.InvoiceItems.Any(it =>
string.Equals(it.Description, description, StringComparison.OrdinalIgnoreCase)
&& it.TotalPrice == record.TotalPrice
&& it.DisplayOrder == record.DisplayOrder);
if (isDuplicate)
{
result.Warnings.Add($"Row {rowNumber}: Line '{description}' already exists on invoice '{record.InvoiceNumber}' — skipped.");
result.SkippedCount++;
continue;
}
// Resolve the optional revenue account by number so revenue attribution is preserved.
int? revenueAccountId = null;
var cleanRevenueAccount = StripQuotes(record.RevenueAccountNumber)?.Trim();
if (!string.IsNullOrWhiteSpace(cleanRevenueAccount))
{
if (accountByNumber.TryGetValue(cleanRevenueAccount, out var revenueAccount))
revenueAccountId = revenueAccount.Id;
else
result.Warnings.Add($"Row {rowNumber}: Revenue account '{cleanRevenueAccount}' not found in Chart of Accounts — line imported without a revenue account.");
}
var item = new Core.Entities.InvoiceItem
{
InvoiceId = invoice.Id,
CompanyId = companyId,
Description = description,
Quantity = record.Quantity,
UnitPrice = record.UnitPrice,
TotalPrice = record.TotalPrice,
ColorName = string.IsNullOrWhiteSpace(record.ColorName) ? null : record.ColorName.Trim(),
RevenueAccountId = revenueAccountId,
DisplayOrder = record.DisplayOrder,
Notes = string.IsNullOrWhiteSpace(record.Notes) ? null : record.Notes.Trim()
};
await _unitOfWork.InvoiceItems.AddAsync(item);
await _unitOfWork.CompleteAsync();
// Keep the in-memory invoice current so later rows dedup against it correctly.
invoice.InvoiceItems.Add(item);
result.SuccessCount++;
}
catch (Exception ex)
{
result.Errors.Add($"Row {rowNumber}: {ex.Message}");
result.ErrorCount++;
}
}
result.Success = result.ErrorCount == 0 || result.SuccessCount > 0;
return result;
}
catch (Exception ex)
{
_logger.LogError(ex, "Fatal error during invoice item CSV import for company {CompanyId}", companyId);
result.Errors.Add($"Fatal error: {ex.Message}");
return result;
}
}
/// <summary>
/// Imports vendor bill headers from CSV. Vendor is resolved by name and the AP account by number.
/// Dedup by BillNumber. Line items import separately via <see cref="ImportBillLineItemsAsync"/>.
/// </summary>
public async Task<CsvImportResultDto> ImportBillsAsync(Stream csvStream, int companyId)
{
var result = new CsvImportResultDto();
var rowNumber = 0;
try
{
using var reader = new StreamReader(csvStream);
using var csv = new CsvReader(reader, new CsvConfiguration(CultureInfo.InvariantCulture)
{
HeaderValidated = null,
MissingFieldFound = null
});
var records = csv.GetRecords<BillImportDto>().ToList();
result.TotalRows = records.Count;
var accounts = await _unitOfWork.Accounts.GetAllAsync();
var accountByNumber = accounts.Where(a => !string.IsNullOrEmpty(a.AccountNumber))
.GroupBy(a => a.AccountNumber.Trim())
.ToDictionary(g => g.Key, g => g.First(), StringComparer.OrdinalIgnoreCase);
var vendors = await _unitOfWork.Vendors.GetAllAsync();
var vendorByName = vendors.Where(v => !string.IsNullOrEmpty(v.CompanyName))
.GroupBy(v => v.CompanyName.Trim())
.ToDictionary(g => g.Key, g => g.First(), StringComparer.OrdinalIgnoreCase);
var existingBills = await _unitOfWork.Bills.GetAllAsync();
var existingBillNumbers = existingBills.Where(b => !string.IsNullOrEmpty(b.BillNumber))
.Select(b => b.BillNumber.Trim()).ToHashSet(StringComparer.OrdinalIgnoreCase);
foreach (var record in records)
{
rowNumber++;
try
{
var billNumber = StripQuotes(record.BillNumber)?.Trim() ?? "";
if (string.IsNullOrWhiteSpace(billNumber))
{
result.Errors.Add($"Row {rowNumber}: BillNumber is required.");
result.ErrorCount++;
continue;
}
if (existingBillNumbers.Contains(billNumber))
{
result.Warnings.Add($"Row {rowNumber}: Bill '{billNumber}' already exists — skipped.");
result.SkippedCount++;
continue;
}
var cleanVendor = StripQuotes(record.VendorName)?.Trim() ?? "";
if (!vendorByName.TryGetValue(cleanVendor, out var vendor))
{
result.Errors.Add($"Row {rowNumber}: Vendor '{cleanVendor}' not found.");
result.ErrorCount++;
continue;
}
var cleanApAccount = StripQuotes(record.APAccountNumber)?.Trim() ?? "";
if (!accountByNumber.TryGetValue(cleanApAccount, out var apAccount))
{
result.Errors.Add($"Row {rowNumber}: AP account '{cleanApAccount}' not found in Chart of Accounts.");
result.ErrorCount++;
continue;
}
if (!Enum.TryParse<BillStatus>(record.Status?.Trim(), true, out var status))
status = BillStatus.Open;
var bill = new Core.Entities.Bill
{
CompanyId = companyId,
BillNumber = billNumber,
VendorInvoiceNumber = string.IsNullOrWhiteSpace(record.VendorInvoiceNumber) ? null : record.VendorInvoiceNumber.Trim(),
VendorId = vendor.Id,
APAccountId = apAccount.Id,
BillDate = record.BillDate == default ? DateTime.UtcNow.Date : record.BillDate,
DueDate = record.DueDate,
Status = status,
Terms = string.IsNullOrWhiteSpace(record.Terms) ? null : record.Terms.Trim(),
Memo = string.IsNullOrWhiteSpace(record.Memo) ? null : record.Memo.Trim(),
SubTotal = record.SubTotal,
TaxPercent = record.TaxPercent,
TaxAmount = record.TaxAmount,
Total = record.Total,
AmountPaid = record.AmountPaid
};
await _unitOfWork.Bills.AddAsync(bill);
await _unitOfWork.CompleteAsync();
existingBillNumbers.Add(billNumber);
result.SuccessCount++;
}
catch (Exception ex)
{
result.Errors.Add($"Row {rowNumber}: {ex.Message}");
result.ErrorCount++;
}
}
result.Success = result.ErrorCount == 0 || result.SuccessCount > 0;
return result;
}
catch (Exception ex)
{
_logger.LogError(ex, "Fatal error during bill CSV import for company {CompanyId}", companyId);
result.Errors.Add($"Fatal error: {ex.Message}");
return result;
}
}
/// <summary>
/// Imports vendor bill line items from CSV. Each line is matched to its parent bill by BillNumber;
/// the expense/asset account (optional) and job (optional) are resolved by number. Idempotent by
/// bill + description + amount + display order. Run after bills have been imported.
/// </summary>
public async Task<CsvImportResultDto> ImportBillLineItemsAsync(Stream csvStream, int companyId)
{
var result = new CsvImportResultDto();
var rowNumber = 0;
try
{
using var reader = new StreamReader(csvStream);
using var csv = new CsvReader(reader, new CsvConfiguration(CultureInfo.InvariantCulture)
{
HeaderValidated = null,
MissingFieldFound = null
});
var records = csv.GetRecords<BillLineItemImportDto>().ToList();
result.TotalRows = records.Count;
var bills = await _unitOfWork.Bills.GetAllAsync(false, b => b.LineItems);
var billByNumber = bills.Where(b => !string.IsNullOrEmpty(b.BillNumber))
.ToDictionary(b => b.BillNumber.Trim(), b => b, StringComparer.OrdinalIgnoreCase);
var accounts = await _unitOfWork.Accounts.GetAllAsync();
var accountByNumber = accounts.Where(a => !string.IsNullOrEmpty(a.AccountNumber))
.GroupBy(a => a.AccountNumber.Trim())
.ToDictionary(g => g.Key, g => g.First(), StringComparer.OrdinalIgnoreCase);
var jobs = await _unitOfWork.Jobs.GetAllAsync();
var jobByNumber = jobs.Where(j => !string.IsNullOrEmpty(j.JobNumber))
.ToDictionary(j => j.JobNumber.Trim(), j => j, StringComparer.OrdinalIgnoreCase);
foreach (var record in records)
{
rowNumber++;
try
{
var billNumber = StripQuotes(record.BillNumber)?.Trim() ?? "";
if (!billByNumber.TryGetValue(billNumber, out var bill))
{
result.Errors.Add($"Row {rowNumber}: Bill '{record.BillNumber}' not found.");
result.ErrorCount++;
continue;
}
var description = StripQuotes(record.Description)?.Trim() ?? "";
var isDuplicate = bill.LineItems.Any(li =>
string.Equals(li.Description, description, StringComparison.OrdinalIgnoreCase)
&& li.Amount == record.Amount && li.DisplayOrder == record.DisplayOrder);
if (isDuplicate)
{
result.Warnings.Add($"Row {rowNumber}: Line '{description}' already exists on bill '{billNumber}' — skipped.");
result.SkippedCount++;
continue;
}
int? accountId = null;
var cleanAccount = StripQuotes(record.AccountNumber)?.Trim();
if (!string.IsNullOrWhiteSpace(cleanAccount))
{
if (accountByNumber.TryGetValue(cleanAccount, out var account))
accountId = account.Id;
else
result.Warnings.Add($"Row {rowNumber}: Account '{cleanAccount}' not found — line imported without an account.");
}
int? jobId = null;
var cleanJob = StripQuotes(record.JobNumber)?.Trim();
if (!string.IsNullOrWhiteSpace(cleanJob) && jobByNumber.TryGetValue(cleanJob, out var job))
jobId = job.Id;
var lineItem = new Core.Entities.BillLineItem
{
CompanyId = companyId,
BillId = bill.Id,
AccountId = accountId,
JobId = jobId,
Description = description,
Quantity = record.Quantity,
UnitPrice = record.UnitPrice,
Amount = record.Amount,
DisplayOrder = record.DisplayOrder
};
await _unitOfWork.BillLineItems.AddAsync(lineItem);
await _unitOfWork.CompleteAsync();
bill.LineItems.Add(lineItem);
result.SuccessCount++;
}
catch (Exception ex)
{
result.Errors.Add($"Row {rowNumber}: {ex.Message}");
result.ErrorCount++;
}
}
result.Success = result.ErrorCount == 0 || result.SuccessCount > 0;
return result;
}
catch (Exception ex)
{
_logger.LogError(ex, "Fatal error during bill line item CSV import for company {CompanyId}", companyId);
result.Errors.Add($"Fatal error: {ex.Message}");
return result;
}
}
/// <summary>
/// Imports customer deposits from CSV. Customer is resolved by name, the bank account by number,
/// and the optional applied invoice by number. Dedup by ReceiptNumber.
/// </summary>
public async Task<CsvImportResultDto> ImportDepositsAsync(Stream csvStream, int companyId)
{
var result = new CsvImportResultDto();
var rowNumber = 0;
try
{
using var reader = new StreamReader(csvStream);
using var csv = new CsvReader(reader, new CsvConfiguration(CultureInfo.InvariantCulture)
{
HeaderValidated = null,
MissingFieldFound = null
});
var records = csv.GetRecords<DepositImportDto>().ToList();
result.TotalRows = records.Count;
var accounts = await _unitOfWork.Accounts.GetAllAsync();
var accountByNumber = accounts.Where(a => !string.IsNullOrEmpty(a.AccountNumber))
.GroupBy(a => a.AccountNumber.Trim())
.ToDictionary(g => g.Key, g => g.First(), StringComparer.OrdinalIgnoreCase);
var customers = await _unitOfWork.Customers.GetAllAsync();
var customerByName = new Dictionary<string, Core.Entities.Customer>(StringComparer.OrdinalIgnoreCase);
foreach (var c in customers)
{
var name = !string.IsNullOrWhiteSpace(c.CompanyName)
? c.CompanyName.Trim()
: $"{c.ContactFirstName} {c.ContactLastName}".Trim();
if (!string.IsNullOrWhiteSpace(name)) customerByName[name] = c;
}
var invoices = await _unitOfWork.Invoices.GetAllAsync();
var invoiceByNumber = invoices.Where(i => !string.IsNullOrEmpty(i.InvoiceNumber))
.ToDictionary(i => i.InvoiceNumber.Trim(), i => i, StringComparer.OrdinalIgnoreCase);
var validMethods = Enum.GetNames<PaymentMethod>()
.ToDictionary(n => n.ToLower(), n => Enum.Parse<PaymentMethod>(n), StringComparer.OrdinalIgnoreCase);
var existingReceipts = (await _unitOfWork.Deposits.GetAllAsync())
.Where(d => !string.IsNullOrEmpty(d.ReceiptNumber))
.Select(d => d.ReceiptNumber.Trim()).ToHashSet(StringComparer.OrdinalIgnoreCase);
foreach (var record in records)
{
rowNumber++;
try
{
var receiptNumber = StripQuotes(record.ReceiptNumber)?.Trim() ?? "";
if (!string.IsNullOrWhiteSpace(receiptNumber) && existingReceipts.Contains(receiptNumber))
{
result.Warnings.Add($"Row {rowNumber}: Deposit '{receiptNumber}' already exists — skipped.");
result.SkippedCount++;
continue;
}
var cleanCustomer = StripQuotes(record.CustomerName)?.Trim() ?? "";
if (!customerByName.TryGetValue(cleanCustomer, out var customer))
{
result.Errors.Add($"Row {rowNumber}: Customer '{cleanCustomer}' not found.");
result.ErrorCount++;
continue;
}
if (!validMethods.TryGetValue(record.PaymentMethod?.Trim() ?? "", out var method))
method = PaymentMethod.Cash;
int? depositAccountId = null;
var cleanDepositAccount = StripQuotes(record.DepositAccountNumber)?.Trim();
if (!string.IsNullOrWhiteSpace(cleanDepositAccount))
{
if (accountByNumber.TryGetValue(cleanDepositAccount, out var depositAccount))
depositAccountId = depositAccount.Id;
else
result.Warnings.Add($"Row {rowNumber}: Deposit account '{cleanDepositAccount}' not found — deposit imported without a bank account.");
}
int? appliedInvoiceId = null;
var cleanInvoice = StripQuotes(record.AppliedToInvoiceNumber)?.Trim();
if (!string.IsNullOrWhiteSpace(cleanInvoice) && invoiceByNumber.TryGetValue(cleanInvoice, out var appliedInvoice))
appliedInvoiceId = appliedInvoice.Id;
var deposit = new Core.Entities.Deposit
{
CompanyId = companyId,
ReceiptNumber = receiptNumber,
CustomerId = customer.Id,
Amount = record.Amount,
PaymentMethod = method,
ReceivedDate = record.ReceivedDate == default ? DateTime.UtcNow.Date : record.ReceivedDate,
DepositAccountId = depositAccountId,
AppliedToInvoiceId = appliedInvoiceId,
AppliedDate = record.AppliedDate,
Reference = string.IsNullOrWhiteSpace(record.Reference) ? null : record.Reference.Trim(),
Notes = string.IsNullOrWhiteSpace(record.Notes) ? null : record.Notes.Trim()
};
await _unitOfWork.Deposits.AddAsync(deposit);
await _unitOfWork.CompleteAsync();
if (!string.IsNullOrWhiteSpace(receiptNumber)) existingReceipts.Add(receiptNumber);
result.SuccessCount++;
}
catch (Exception ex)
{
result.Errors.Add($"Row {rowNumber}: {ex.Message}");
result.ErrorCount++;
}
}
result.Success = result.ErrorCount == 0 || result.SuccessCount > 0;
return result;
}
catch (Exception ex)
{
_logger.LogError(ex, "Fatal error during deposit CSV import for company {CompanyId}", companyId);
result.Errors.Add($"Fatal error: {ex.Message}");
return result;
}
}
/// <summary>
/// Imports journal entry headers from CSV. Dedup by EntryNumber. The debit/credit lines import
/// separately via <see cref="ImportJournalEntryLinesAsync"/>.
/// </summary>
public async Task<CsvImportResultDto> ImportJournalEntriesAsync(Stream csvStream, int companyId)
{
var result = new CsvImportResultDto();
var rowNumber = 0;
try
{
using var reader = new StreamReader(csvStream);
using var csv = new CsvReader(reader, new CsvConfiguration(CultureInfo.InvariantCulture)
{
HeaderValidated = null,
MissingFieldFound = null
});
var records = csv.GetRecords<JournalEntryImportDto>().ToList();
result.TotalRows = records.Count;
var existingNumbers = (await _unitOfWork.JournalEntries.GetAllAsync())
.Where(j => !string.IsNullOrEmpty(j.EntryNumber))
.Select(j => j.EntryNumber.Trim()).ToHashSet(StringComparer.OrdinalIgnoreCase);
foreach (var record in records)
{
rowNumber++;
try
{
var entryNumber = StripQuotes(record.EntryNumber)?.Trim() ?? "";
if (string.IsNullOrWhiteSpace(entryNumber))
{
result.Errors.Add($"Row {rowNumber}: EntryNumber is required.");
result.ErrorCount++;
continue;
}
if (existingNumbers.Contains(entryNumber))
{
result.Warnings.Add($"Row {rowNumber}: Journal entry '{entryNumber}' already exists — skipped.");
result.SkippedCount++;
continue;
}
if (!Enum.TryParse<JournalEntryStatus>(record.Status?.Trim(), true, out var status))
status = JournalEntryStatus.Draft;
var entry = new Core.Entities.JournalEntry
{
CompanyId = companyId,
EntryNumber = entryNumber,
EntryDate = record.EntryDate == default ? DateTime.UtcNow.Date : record.EntryDate,
Reference = string.IsNullOrWhiteSpace(record.Reference) ? null : record.Reference.Trim(),
Description = string.IsNullOrWhiteSpace(record.Description) ? null : record.Description.Trim(),
Status = status,
PostedAt = status == JournalEntryStatus.Posted ? DateTime.UtcNow : null
};
await _unitOfWork.JournalEntries.AddAsync(entry);
await _unitOfWork.CompleteAsync();
existingNumbers.Add(entryNumber);
result.SuccessCount++;
}
catch (Exception ex)
{
result.Errors.Add($"Row {rowNumber}: {ex.Message}");
result.ErrorCount++;
}
}
result.Success = result.ErrorCount == 0 || result.SuccessCount > 0;
return result;
}
catch (Exception ex)
{
_logger.LogError(ex, "Fatal error during journal entry CSV import for company {CompanyId}", companyId);
result.Errors.Add($"Fatal error: {ex.Message}");
return result;
}
}
/// <summary>
/// Imports journal entry lines from CSV. Each line is matched to its parent entry by EntryNumber
/// and the account resolved (required) from AccountNumber. Idempotent by entry + account + amounts
/// + line order. Run after journal entry headers have been imported.
/// </summary>
public async Task<CsvImportResultDto> ImportJournalEntryLinesAsync(Stream csvStream, int companyId)
{
var result = new CsvImportResultDto();
var rowNumber = 0;
try
{
using var reader = new StreamReader(csvStream);
using var csv = new CsvReader(reader, new CsvConfiguration(CultureInfo.InvariantCulture)
{
HeaderValidated = null,
MissingFieldFound = null
});
var records = csv.GetRecords<JournalEntryLineImportDto>().ToList();
result.TotalRows = records.Count;
var entries = await _unitOfWork.JournalEntries.GetAllAsync(false, j => j.Lines);
var entryByNumber = entries.Where(j => !string.IsNullOrEmpty(j.EntryNumber))
.ToDictionary(j => j.EntryNumber.Trim(), j => j, StringComparer.OrdinalIgnoreCase);
var accounts = await _unitOfWork.Accounts.GetAllAsync();
var accountByNumber = accounts.Where(a => !string.IsNullOrEmpty(a.AccountNumber))
.GroupBy(a => a.AccountNumber.Trim())
.ToDictionary(g => g.Key, g => g.First(), StringComparer.OrdinalIgnoreCase);
foreach (var record in records)
{
rowNumber++;
try
{
var entryNumber = StripQuotes(record.EntryNumber)?.Trim() ?? "";
if (!entryByNumber.TryGetValue(entryNumber, out var entry))
{
result.Errors.Add($"Row {rowNumber}: Journal entry '{record.EntryNumber}' not found.");
result.ErrorCount++;
continue;
}
var cleanAccount = StripQuotes(record.AccountNumber)?.Trim() ?? "";
if (!accountByNumber.TryGetValue(cleanAccount, out var account))
{
result.Errors.Add($"Row {rowNumber}: Account '{cleanAccount}' not found in Chart of Accounts.");
result.ErrorCount++;
continue;
}
var isDuplicate = entry.Lines.Any(l =>
l.AccountId == account.Id && l.DebitAmount == record.DebitAmount
&& l.CreditAmount == record.CreditAmount && l.LineOrder == record.LineOrder);
if (isDuplicate)
{
result.Warnings.Add($"Row {rowNumber}: Line for account '{cleanAccount}' already exists on entry '{entryNumber}' — skipped.");
result.SkippedCount++;
continue;
}
var line = new Core.Entities.JournalEntryLine
{
CompanyId = companyId,
JournalEntryId = entry.Id,
AccountId = account.Id,
DebitAmount = record.DebitAmount,
CreditAmount = record.CreditAmount,
Description = string.IsNullOrWhiteSpace(record.Description) ? null : record.Description.Trim(),
LineOrder = record.LineOrder
};
await _unitOfWork.JournalEntryLines.AddAsync(line);
await _unitOfWork.CompleteAsync();
entry.Lines.Add(line);
result.SuccessCount++;
}
catch (Exception ex)
{
result.Errors.Add($"Row {rowNumber}: {ex.Message}");
result.ErrorCount++;
}
}
result.Success = result.ErrorCount == 0 || result.SuccessCount > 0;
return result;
}
catch (Exception ex)
{
_logger.LogError(ex, "Fatal error during journal entry line CSV import for company {CompanyId}", companyId);
result.Errors.Add($"Fatal error: {ex.Message}");
return result;
}
}
public async Task<CsvImportResultDto> ImportPaymentsAsync(Stream csvStream, int companyId)
{
var result = new CsvImportResultDto();
@@ -3933,14 +3256,6 @@ public class CsvImportService : ICsvImportService
var invoiceByNumber = invoices.Where(i => !string.IsNullOrEmpty(i.InvoiceNumber))
.ToDictionary(i => i.InvoiceNumber.Trim(), i => i, StringComparer.OrdinalIgnoreCase);
// Account lookup for resolving the deposit (bank) account by number — optional per row.
// Mirrors the expense import so payments round-trip with their bank-account linkage intact.
var accounts = await _unitOfWork.Accounts.GetAllAsync();
var accountByNumber = accounts
.Where(a => !string.IsNullOrEmpty(a.AccountNumber))
.GroupBy(a => a.AccountNumber.Trim())
.ToDictionary(g => g.Key, g => g.First(), StringComparer.OrdinalIgnoreCase);
var validMethods = Enum.GetNames<PaymentMethod>()
.ToDictionary(n => n.ToLower(), n => Enum.Parse<PaymentMethod>(n), StringComparer.OrdinalIgnoreCase);
@@ -3986,28 +3301,15 @@ public class CsvImportService : ICsvImportService
method = PaymentMethod.Cash;
}
// Resolve the optional deposit (bank) account by number so the balance recalc can
// post this payment. A blank value is fine; an unknown number warns but still imports.
int? depositAccountId = null;
var cleanDepositAccount = StripQuotes(record.DepositAccountNumber)?.Trim();
if (!string.IsNullOrWhiteSpace(cleanDepositAccount))
{
if (accountByNumber.TryGetValue(cleanDepositAccount, out var depositAccount))
depositAccountId = depositAccount.Id;
else
result.Warnings.Add($"Row {rowNumber}: Deposit account '{cleanDepositAccount}' not found in Chart of Accounts — payment imported without a deposit account.");
}
var payment = new Core.Entities.Payment
{
InvoiceId = invoice.Id,
CompanyId = companyId,
Amount = record.Amount,
PaymentDate = new DateTime(paymentDate.Year, paymentDate.Month, paymentDate.Day, 0, 0, 0, DateTimeKind.Utc),
PaymentMethod = method,
DepositAccountId = depositAccountId,
Reference = string.IsNullOrWhiteSpace(record.Reference) ? null : record.Reference.Trim(),
Notes = string.IsNullOrWhiteSpace(record.Notes) ? null : record.Notes.Trim()
InvoiceId = invoice.Id,
CompanyId = companyId,
Amount = record.Amount,
PaymentDate = new DateTime(paymentDate.Year, paymentDate.Month, paymentDate.Day, 0, 0, 0, DateTimeKind.Utc),
PaymentMethod = method,
Reference = string.IsNullOrWhiteSpace(record.Reference) ? null : record.Reference.Trim(),
Notes = string.IsNullOrWhiteSpace(record.Notes) ? null : record.Notes.Trim()
};
await _unitOfWork.Payments.AddAsync(payment);
@@ -1,7 +1,6 @@
using Microsoft.EntityFrameworkCore;
using PowderCoating.Application.DTOs.Accounting;
using PowderCoating.Application.Interfaces;
using PowderCoating.Core.Accounting;
using PowderCoating.Core.Entities;
using PowderCoating.Core.Enums;
using PowderCoating.Infrastructure.Data;
@@ -19,65 +18,10 @@ namespace PowderCoating.Infrastructure.Services;
public class FinancialReportService : IFinancialReportService
{
private readonly ApplicationDbContext _context;
private readonly ILedgerService _ledger;
public FinancialReportService(ApplicationDbContext context, ILedgerService ledger)
public FinancialReportService(ApplicationDbContext context)
{
_context = context;
_ledger = ledger;
}
/// <inheritdoc/>
public async Task<BalanceReconciliationDto> GetBalanceReconciliationAsync(int companyId)
{
var companyName = await GetCompanyNameAsync(companyId);
var accounts = await _context.Accounts
.Where(a => a.CompanyId == companyId && a.IsActive && !a.IsDeleted)
.OrderBy(a => a.AccountNumber)
.ToListAsync();
// Epoch start so LedgerService treats OpeningBalance as prior and all activity falls in-window —
// identical to how AccountBalanceService.RecalculateAllAsync derives the authoritative balance.
var epoch = new DateTime(2000, 1, 1);
var now = DateTime.UtcNow;
var lines = new List<BalanceReconciliationLine>();
decimal arControl = 0m, apControl = 0m;
foreach (var a in accounts)
{
var ledger = await _ledger.GetAccountLedgerAsync(a.Id, epoch, now);
var ledgerBalance = ledger?.ClosingBalance ?? 0m;
lines.Add(new BalanceReconciliationLine
{
AccountId = a.Id,
AccountNumber = a.AccountNumber,
AccountName = a.Name,
AccountType = a.AccountType,
StoredBalance = a.CurrentBalance,
LedgerBalance = ledgerBalance
});
if (a.AccountSubType == AccountSubType.AccountsReceivable) arControl += ledgerBalance;
if (a.AccountSubType == AccountSubType.AccountsPayable) apControl += ledgerBalance;
}
var arSubledger = await _context.Customers
.Where(c => c.CompanyId == companyId && !c.IsDeleted)
.SumAsync(c => (decimal?)c.CurrentBalance) ?? 0m;
var apSubledger = await _context.Vendors
.Where(v => v.CompanyId == companyId && !v.IsDeleted)
.SumAsync(v => (decimal?)v.CurrentBalance) ?? 0m;
return new BalanceReconciliationDto
{
AsOf = now,
CompanyName = companyName,
AccountLines = lines,
ArControlBalance = arControl,
ArSubledgerTotal = arSubledger,
ApControlBalance = apControl,
ApSubledgerTotal = apSubledger
};
}
/// <inheritdoc/>
@@ -89,7 +33,7 @@ public class FinancialReportService : IFinancialReportService
var isCash = accountingMethod == AccountingMethod.Cash;
var revenueAccounts = await _context.Accounts
.Where(a => a.CompanyId == companyId && a.AccountType == AccountType.Revenue && a.IsActive)
.Where(a => a.AccountType == AccountType.Revenue && a.IsActive)
.ToDictionaryAsync(a => a.Id);
var revenueLines = new List<FinancialReportLine>();
@@ -98,26 +42,17 @@ public class FinancialReportService : IFinancialReportService
{
// Cash basis: total payments received in period (not split by revenue account)
var cashRevenue = await _context.Payments
.Where(p => p.CompanyId == companyId && p.PaymentDate >= from && p.PaymentDate <= toEnd
.Where(p => p.PaymentDate >= from && p.PaymentDate <= toEnd
&& p.Invoice.Status != InvoiceStatus.Voided)
.SumAsync(p => (decimal?)p.Amount) ?? 0;
if (cashRevenue > 0)
revenueLines.Add(new FinancialReportLine { AccountNumber = "—", AccountName = "Cash Receipts", Amount = cashRevenue });
// Cash refunds are cash paid back out — they reduce cash-basis revenue.
var cashRefunds = await _context.Refunds
.Where(r => r.CompanyId == companyId && !r.IsDeleted && r.RefundMethod != PaymentMethod.StoreCredit
&& r.RefundDate >= from && r.RefundDate <= toEnd)
.SumAsync(r => (decimal?)r.Amount) ?? 0m;
if (cashRefunds > 0)
revenueLines.Add(new FinancialReportLine { AccountNumber = "4960", AccountName = "Less: Refunds Paid", Amount = -cashRefunds });
}
else
{
// Accrual basis: revenue = invoice item amounts by invoice date
var accrualRevenue = await _context.InvoiceItems
.Where(ii => ii.CompanyId == companyId
&& ii.RevenueAccountId != null
.Where(ii => ii.RevenueAccountId != null
&& ii.Invoice.Status != InvoiceStatus.Draft
&& ii.Invoice.Status != InvoiceStatus.Voided
&& ii.Invoice.InvoiceDate >= from && ii.Invoice.InvoiceDate <= toEnd)
@@ -137,8 +72,7 @@ public class FinancialReportService : IFinancialReportService
.OrderBy(l => l.AccountNumber));
var unlinkedRevenue = await _context.InvoiceItems
.Where(ii => ii.CompanyId == companyId
&& ii.RevenueAccountId == null
.Where(ii => ii.RevenueAccountId == null
&& ii.Invoice.Status != InvoiceStatus.Draft
&& ii.Invoice.Status != InvoiceStatus.Voided
&& ii.Invoice.InvoiceDate >= from && ii.Invoice.InvoiceDate <= toEnd)
@@ -148,19 +82,13 @@ public class FinancialReportService : IFinancialReportService
// Contra-revenue: discounts granted and credit memos applied reduce gross revenue.
var periodDiscounts = await _context.Invoices
.Where(i => i.CompanyId == companyId && i.Status != InvoiceStatus.Draft && i.Status != InvoiceStatus.Voided
.Where(i => i.Status != InvoiceStatus.Draft && i.Status != InvoiceStatus.Voided
&& i.DiscountAmount > 0 && i.InvoiceDate >= from && i.InvoiceDate <= toEnd)
.SumAsync(i => (decimal?)i.DiscountAmount) ?? 0m;
// Credit-memo contra-revenue is recognized at issue (DR Sales Discounts). Net for the period =
// memos issued in the period minus the unapplied remainder of memos voided in the period.
var periodCmIssued = await _context.CreditMemos
.Where(m => m.CompanyId == companyId && m.IssueDate >= from && m.IssueDate <= toEnd)
.SumAsync(m => (decimal?)m.Amount) ?? 0m;
var periodCmVoided = await _context.CreditMemos
.Where(m => m.CompanyId == companyId && m.Status == CreditMemoStatus.Voided
&& m.UpdatedAt >= from && m.UpdatedAt <= toEnd)
.SumAsync(m => (decimal?)(m.Amount - m.AmountApplied)) ?? 0m;
var periodCredits = periodCmIssued - periodCmVoided;
var periodCredits = await _context.CreditMemoApplications
.Where(a => a.AppliedDate >= from && a.AppliedDate <= toEnd
&& a.Invoice.Status != InvoiceStatus.Voided)
.SumAsync(a => (decimal?)a.AmountApplied) ?? 0m;
var totalDeductions = periodDiscounts + periodCredits;
if (totalDeductions > 0)
revenueLines.Add(new FinancialReportLine
@@ -170,26 +98,9 @@ public class FinancialReportService : IFinancialReportService
Amount = -totalDeductions
});
// Cash refunds reverse the sale — the revenue portion is contra-revenue (the tax portion
// relieves Sales Tax Payable, not revenue). Store-credit refunds are excluded (no GL posting).
var periodRefunds = await _context.Refunds
.Where(r => r.CompanyId == companyId && !r.IsDeleted && r.Invoice != null && r.RefundMethod != PaymentMethod.StoreCredit
&& r.RefundDate >= from && r.RefundDate <= toEnd)
.Select(r => new { r.Amount, r.Invoice!.TaxAmount, r.Invoice.Total })
.ToListAsync();
var periodRefundReturns = periodRefunds.Sum(r => RefundAllocation.Split(r.Amount, r.TaxAmount, r.Total).ReturnsPortion);
if (periodRefundReturns > 0)
revenueLines.Add(new FinancialReportLine
{
AccountNumber = "4960",
AccountName = "Less: Sales Returns",
Amount = -periodRefundReturns
});
// GC sales are deferred to GC Liability at issuance; revenue is recognized on redemption.
var periodGcReclassified = await _context.InvoiceItems
.Where(ii => ii.CompanyId == companyId
&& ii.IsGiftCertificate
.Where(ii => ii.IsGiftCertificate
&& ii.Invoice.Status != InvoiceStatus.Draft
&& ii.Invoice.Status != InvoiceStatus.Voided
&& ii.Invoice.InvoiceDate >= from && ii.Invoice.InvoiceDate <= toEnd)
@@ -204,7 +115,7 @@ public class FinancialReportService : IFinancialReportService
// Voided GCs with remaining balance are breakage income (liability extinguished).
var periodGcBreakage = await _context.GiftCertificates
.Where(gc => gc.CompanyId == companyId && !gc.IsDeleted && gc.Status == GiftCertificateStatus.Voided
.Where(gc => !gc.IsDeleted && gc.Status == GiftCertificateStatus.Voided
&& gc.UpdatedAt >= from && gc.UpdatedAt <= toEnd
&& gc.OriginalAmount > gc.RedeemedAmount)
.SumAsync(gc => (decimal?)(gc.OriginalAmount - gc.RedeemedAmount)) ?? 0m;
@@ -223,7 +134,7 @@ public class FinancialReportService : IFinancialReportService
if (isCash)
{
var cashExpenses = await _context.Expenses
.Where(e => e.CompanyId == companyId && e.Date >= from && e.Date <= toEnd)
.Where(e => e.Date >= from && e.Date <= toEnd)
.GroupBy(e => e.ExpenseAccountId)
.Select(g => new { AccountId = g.Key, Amount = g.Sum(e => e.Amount) })
.ToListAsync();
@@ -232,7 +143,7 @@ public class FinancialReportService : IFinancialReportService
// Pro-rate paid bill line items by payment fraction (bill total may be partial)
var paidBillLines = await _context.BillPayments
.Where(bp => bp.CompanyId == companyId && bp.PaymentDate >= from && bp.PaymentDate <= toEnd)
.Where(bp => bp.PaymentDate >= from && bp.PaymentDate <= toEnd)
.Include(bp => bp.Bill).ThenInclude(b => b.LineItems)
.ToListAsync();
foreach (var bp in paidBillLines)
@@ -245,7 +156,7 @@ public class FinancialReportService : IFinancialReportService
else
{
var accrualExpenses = await _context.Expenses
.Where(e => e.CompanyId == companyId && e.Date >= from && e.Date <= toEnd)
.Where(e => e.Date >= from && e.Date <= toEnd)
.GroupBy(e => e.ExpenseAccountId)
.Select(g => new { AccountId = g.Key, Amount = g.Sum(e => e.Amount) })
.ToListAsync();
@@ -253,8 +164,7 @@ public class FinancialReportService : IFinancialReportService
expenseAmounts[e.AccountId] = expenseAmounts.GetValueOrDefault(e.AccountId) + e.Amount;
var accrualBillLines = await _context.BillLineItems
.Where(bli => bli.CompanyId == companyId
&& bli.AccountId != null
.Where(bli => bli.AccountId != null
&& bli.Bill.Status != BillStatus.Draft
&& bli.Bill.Status != BillStatus.Voided
&& bli.Bill.BillDate >= from && bli.Bill.BillDate <= toEnd)
@@ -263,23 +173,10 @@ public class FinancialReportService : IFinancialReportService
.ToListAsync();
foreach (var b in accrualBillLines)
expenseAmounts[b.AccountId] = expenseAmounts.GetValueOrDefault(b.AccountId) + b.Amount;
// Inventory consumed on jobs posts DR COGS / CR Inventory — recognise the COGS in the period.
// (Cash basis recognises inventory cost when purchased, so this applies to accrual only.)
var consumptionCogs = await _context.InventoryTransactions
.Where(t => t.CompanyId == companyId
&& (t.TransactionType == InventoryTransactionType.JobUsage || t.TransactionType == InventoryTransactionType.Waste)
&& t.InventoryItem.CogsAccountId != null && t.InventoryItem.InventoryAccountId != null
&& t.TransactionDate >= from && t.TransactionDate <= toEnd)
.GroupBy(t => t.InventoryItem.CogsAccountId!.Value)
.Select(g => new { AccountId = g.Key, Amount = g.Sum(t => t.TotalCost) })
.ToListAsync();
foreach (var c in consumptionCogs)
expenseAmounts[c.AccountId] = expenseAmounts.GetValueOrDefault(c.AccountId) + c.Amount;
}
var expAccounts = await _context.Accounts
.Where(a => a.CompanyId == companyId && (a.AccountType == AccountType.Expense || a.AccountType == AccountType.CostOfGoods) && a.IsActive)
.Where(a => (a.AccountType == AccountType.Expense || a.AccountType == AccountType.CostOfGoods) && a.IsActive)
.ToDictionaryAsync(a => a.Id);
var cogsLines = new List<FinancialReportLine>();
@@ -319,45 +216,46 @@ public class FinancialReportService : IFinancialReportService
// Pre-compute balance contributions per account (batch GROUP BY queries avoid N+1)
var depositsByAcct = await _context.Payments
.Where(p => p.CompanyId == companyId && p.PaymentDate <= asOfEnd && p.DepositAccountId != null
&& p.Invoice.Status != InvoiceStatus.Voided)
.Where(p => p.PaymentDate <= asOfEnd && p.DepositAccountId != null
&& p.Invoice.Status != InvoiceStatus.Voided
&& p.Invoice.Status != InvoiceStatus.WrittenOff)
.GroupBy(p => p.DepositAccountId!.Value)
.Select(g => new { Id = g.Key, Amount = g.Sum(p => p.Amount) })
.ToDictionaryAsync(g => g.Id, g => g.Amount);
var expFromByAcct = await _context.Expenses
.Where(e => e.CompanyId == companyId && e.Date <= asOfEnd)
.Where(e => e.Date <= asOfEnd)
.GroupBy(e => e.PaymentAccountId)
.Select(g => new { Id = g.Key, Amount = g.Sum(e => e.Amount) })
.ToDictionaryAsync(g => g.Id, g => g.Amount);
var bpFromByAcct = await _context.BillPayments
.Where(bp => bp.CompanyId == companyId && bp.PaymentDate <= asOfEnd)
.Where(bp => bp.PaymentDate <= asOfEnd)
.GroupBy(bp => bp.BankAccountId)
.Select(g => new { Id = g.Key, Amount = g.Sum(bp => bp.Amount) })
.ToDictionaryAsync(g => g.Id, g => g.Amount);
var billsByApAcct = await _context.Bills
.Where(b => b.CompanyId == companyId && b.Status != BillStatus.Draft && b.Status != BillStatus.Voided && b.BillDate <= asOfEnd)
.Where(b => b.Status != BillStatus.Draft && b.Status != BillStatus.Voided && b.BillDate <= asOfEnd)
.GroupBy(b => b.APAccountId)
.Select(g => new { Id = g.Key, Amount = g.Sum(b => b.Total) })
.ToDictionaryAsync(g => g.Id, g => g.Amount);
var bpByApAcct = await _context.BillPayments
.Where(bp => bp.CompanyId == companyId && bp.PaymentDate <= asOfEnd)
.Where(bp => bp.PaymentDate <= asOfEnd)
.GroupBy(bp => bp.Bill.APAccountId)
.Select(g => new { Id = g.Key, Amount = g.Sum(bp => bp.Amount) })
.ToDictionaryAsync(g => g.Id, g => g.Amount);
// AP: vendor credit applications reduce AP (DR side) when matched against specific bills.
var vcByApAcctBs = await _context.VendorCreditApplications
.Where(vca => vca.CompanyId == companyId && vca.AppliedDate <= asOfEnd)
.Where(vca => vca.AppliedDate <= asOfEnd)
.GroupBy(vca => vca.VendorCredit.APAccountId)
.Select(g => new { Id = g.Key, Amount = g.Sum(vca => vca.Amount) })
.ToDictionaryAsync(g => g.Id, g => g.Amount);
var taxByAcct = await _context.Invoices
.Where(i => i.CompanyId == companyId && i.SalesTaxAccountId != null && i.TaxAmount > 0
.Where(i => i.SalesTaxAccountId != null && i.TaxAmount > 0
&& i.Status != InvoiceStatus.Draft && i.Status != InvoiceStatus.Voided
&& i.InvoiceDate <= asOfEnd)
.GroupBy(i => i.SalesTaxAccountId!.Value)
@@ -365,67 +263,32 @@ public class FinancialReportService : IFinancialReportService
.ToDictionaryAsync(g => g.Id, g => g.Amount);
var arDebits = await _context.Invoices
.Where(i => i.CompanyId == companyId && i.Status != InvoiceStatus.Draft && i.Status != InvoiceStatus.Voided && i.InvoiceDate <= asOfEnd)
.Where(i => i.Status != InvoiceStatus.Draft && i.Status != InvoiceStatus.Voided && i.InvoiceDate <= asOfEnd)
.SumAsync(i => (decimal?)i.Total) ?? 0;
var arCredits = await _context.Payments
.Where(p => p.CompanyId == companyId && p.PaymentDate <= asOfEnd
&& p.Invoice.Status != InvoiceStatus.Voided)
.Where(p => p.PaymentDate <= asOfEnd
&& p.Invoice.Status != InvoiceStatus.Voided
&& p.Invoice.Status != InvoiceStatus.WrittenOff)
.SumAsync(p => (decimal?)p.Amount) ?? 0;
// Credit memo applications reduce open AR (CR AR when a credit is applied to an invoice).
var cmAppliedBs = await _context.CreditMemoApplications
.Where(a => a.CompanyId == companyId && a.AppliedDate <= asOfEnd && a.Invoice.Status != InvoiceStatus.Voided)
arCredits += await _context.CreditMemoApplications
.Where(a => a.AppliedDate <= asOfEnd && a.Invoice.Status != InvoiceStatus.Voided)
.SumAsync(a => (decimal?)a.AmountApplied) ?? 0;
arCredits += cmAppliedBs;
// Gift-certificate redemptions also credit AR (ApplyGiftCertificate posts DR 2500 / CR AR).
// Mirror the posting here so AR is not overstated and the entry's two sides stay balanced.
var gcRedeemedBs = await _context.GiftCertificateRedemptions
.Where(r => r.CompanyId == companyId && !r.IsDeleted && r.RedeemedDate <= asOfEnd
&& r.Invoice.Status != InvoiceStatus.Voided)
.SumAsync(r => (decimal?)r.AmountRedeemed) ?? 0;
arCredits += gcRedeemedBs;
// Customer Credits (2350): a credit memo books DR Sales Discounts / CR Customer Credits on issue,
// then DR Customer Credits / CR AR on apply. Contra-revenue (retained earnings) = issued amount
// (active in full + applied portion of voided); the 2350 liability = unapplied balance on active memos.
var cmIssuedNonVoidedBs = await _context.CreditMemos
.Where(m => m.CompanyId == companyId && m.Status != CreditMemoStatus.Voided && m.IssueDate <= asOfEnd)
.SumAsync(m => (decimal?)m.Amount) ?? 0m;
var cmAppliedNonVoidedBs = await _context.CreditMemoApplications
.Where(a => a.CompanyId == companyId && a.AppliedDate <= asOfEnd && a.Invoice.Status != InvoiceStatus.Voided
&& a.CreditMemo.Status != CreditMemoStatus.Voided)
.SumAsync(a => (decimal?)a.AmountApplied) ?? 0m;
var cmContraRevenueBs = cmIssuedNonVoidedBs + (cmAppliedBs - cmAppliedNonVoidedBs);
var customerCreditsAcctIdBs = await _context.Accounts
.Where(a => a.CompanyId == companyId && a.AccountNumber == "2350" && a.IsActive && !a.IsDeleted)
.Select(a => (int?)a.Id).FirstOrDefaultAsync();
// Cash refunds reverse the sale: revenue portion reduces retained earnings (Sales Returns),
// tax portion relieves Sales Tax Payable, cash leaves the bank (refundsByAcctBs). AR is untouched.
// Store-credit refunds post via CreditMemo, not the GL, so are excluded.
var saleReversingRefundsBs = await _context.Refunds
.Where(r => r.CompanyId == companyId && r.RefundDate <= asOfEnd && !r.IsDeleted && r.Invoice != null
&& r.RefundMethod != PaymentMethod.StoreCredit)
.Select(r => new { r.Amount, r.Invoice!.TaxAmount, r.Invoice.Total, r.Invoice.SalesTaxAccountId })
.ToListAsync();
decimal refundReturnsTotalBs = 0m;
var refundTaxByAcctBs = new Dictionary<int, decimal>();
foreach (var r in saleReversingRefundsBs)
{
var (returnsPortion, taxPortion) = RefundAllocation.Split(r.Amount, r.TaxAmount, r.Total);
refundReturnsTotalBs += returnsPortion;
if (taxPortion != 0m && r.SalesTaxAccountId.HasValue)
refundTaxByAcctBs[r.SalesTaxAccountId.Value] = refundTaxByAcctBs.GetValueOrDefault(r.SalesTaxAccountId.Value) + taxPortion;
}
// Refunds reverse collected payments — they re-open AR so reduce net AR credits.
arCredits -= await _context.Refunds
.Where(r => r.RefundDate <= asOfEnd && !r.IsDeleted)
.SumAsync(r => (decimal?)r.Amount) ?? 0m;
// Refunds by bank account: money that left the account (CR to checking/bank).
var refundsByAcctBs = await _context.Refunds
.Where(r => r.CompanyId == companyId && r.RefundDate <= asOfEnd && !r.IsDeleted && r.DepositAccountId != null)
.Where(r => r.RefundDate <= asOfEnd && !r.IsDeleted && r.DepositAccountId != null)
.GroupBy(r => r.DepositAccountId!.Value)
.Select(g => new { Id = g.Key, Amount = g.Sum(r => r.Amount) })
.ToDictionaryAsync(g => g.Id, g => g.Amount);
// Deposits by bank account: cash received at deposit recording time (DR bank).
var depositsByAcctDepBs = await _context.Deposits
.Where(d => d.CompanyId == companyId && !d.IsDeleted && d.DepositAccountId != null && d.ReceivedDate <= asOfEnd)
.Where(d => !d.IsDeleted && d.DepositAccountId != null && d.ReceivedDate <= asOfEnd)
.GroupBy(d => d.DepositAccountId!.Value)
.Select(g => new { Id = g.Key, Amount = g.Sum(d => d.Amount) })
.ToDictionaryAsync(g => g.Id, g => g.Amount);
@@ -436,11 +299,11 @@ public class FinancialReportService : IFinancialReportService
.Select(a => (int?)a.Id).FirstOrDefaultAsync();
var custDepositsCreditsBs = custDepositsAcctIdBs.HasValue
? (await _context.Deposits
.Where(d => d.CompanyId == companyId && !d.IsDeleted && d.ReceivedDate <= asOfEnd)
.Where(d => !d.IsDeleted && d.ReceivedDate <= asOfEnd)
.SumAsync(d => (decimal?)d.Amount) ?? 0m) : 0m;
var custDepositsDebitsBs = custDepositsAcctIdBs.HasValue
? (await _context.Deposits
.Where(d => d.CompanyId == companyId && !d.IsDeleted && d.AppliedToInvoiceId != null && d.AppliedDate <= asOfEnd)
.Where(d => !d.IsDeleted && d.AppliedToInvoiceId != null && d.AppliedDate <= asOfEnd)
.SumAsync(d => (decimal?)d.Amount) ?? 0m) : 0m;
// Gift Certificate Liability (2500): balance driven by GC issuances, redemptions, and voids.
@@ -449,14 +312,14 @@ public class FinancialReportService : IFinancialReportService
.Select(a => (int?)a.Id).FirstOrDefaultAsync();
var gcLiabilityCreditsBs = gcLiabilityAcctIdBs.HasValue
? (await _context.GiftCertificates
.Where(gc => gc.CompanyId == companyId && !gc.IsDeleted && gc.IssueDate <= asOfEnd)
.Where(gc => !gc.IsDeleted && gc.IssueDate <= asOfEnd)
.SumAsync(gc => (decimal?)gc.OriginalAmount) ?? 0m) : 0m;
var gcLiabilityDebitsBs = gcLiabilityAcctIdBs.HasValue
? ((await _context.GiftCertificateRedemptions
.Where(r => r.CompanyId == companyId && !r.IsDeleted && r.RedeemedDate <= asOfEnd)
.Where(r => !r.IsDeleted && r.RedeemedDate <= asOfEnd)
.SumAsync(r => (decimal?)r.AmountRedeemed) ?? 0m)
+ (await _context.GiftCertificates
.Where(gc => gc.CompanyId == companyId && !gc.IsDeleted && gc.Status == GiftCertificateStatus.Voided
.Where(gc => !gc.IsDeleted && gc.Status == GiftCertificateStatus.Voided
&& gc.UpdatedAt <= asOfEnd && gc.OriginalAmount > gc.RedeemedAmount)
.SumAsync(gc => (decimal?)(gc.OriginalAmount - gc.RedeemedAmount)) ?? 0m)) : 0m;
@@ -465,21 +328,23 @@ public class FinancialReportService : IFinancialReportService
// plus (5) the net effect of any posted journal entries on revenue/expense/COGS accounts
// (accruals, depreciation, year-end closes, and other adjustments not in the tables above).
var lifetimeRevenue = await _context.InvoiceItems
.Where(ii => ii.CompanyId == companyId && ii.Invoice.Status != InvoiceStatus.Draft && ii.Invoice.Status != InvoiceStatus.Voided && ii.Invoice.InvoiceDate <= asOfEnd)
.Where(ii => ii.Invoice.Status != InvoiceStatus.Draft && ii.Invoice.Status != InvoiceStatus.Voided && ii.Invoice.InvoiceDate <= asOfEnd)
.SumAsync(ii => (decimal?)ii.TotalPrice) ?? 0;
var lifetimeDiscounts = isCash ? 0m
: (await _context.Invoices
.Where(i => i.CompanyId == companyId && i.Status != InvoiceStatus.Draft && i.Status != InvoiceStatus.Voided
.Where(i => i.Status != InvoiceStatus.Draft && i.Status != InvoiceStatus.Voided
&& i.DiscountAmount > 0 && i.InvoiceDate <= asOfEnd)
.SumAsync(i => (decimal?)i.DiscountAmount) ?? 0m);
// Credit memos are contra-revenue recognized at issue (DR Sales Discounts). Net revenue is
// reduced by the issued amount (active memos in full + applied portion of voided memos).
var lifetimeCreditMemos = isCash ? 0m : cmContraRevenueBs;
// Credit memos applied to invoices reduce net revenue (contra-revenue, same as discounts).
var lifetimeCreditMemos = isCash ? 0m
: (await _context.CreditMemoApplications
.Where(a => a.AppliedDate <= asOfEnd && a.Invoice.Status != InvoiceStatus.Voided)
.SumAsync(a => (decimal?)a.AmountApplied) ?? 0m);
var lifetimeDirectExp = await _context.Expenses
.Where(e => e.CompanyId == companyId && e.Date <= asOfEnd)
.Where(e => e.Date <= asOfEnd)
.SumAsync(e => (decimal?)e.Amount) ?? 0;
var lifetimeBillCosts = await _context.BillLineItems
.Where(bli => bli.CompanyId == companyId && bli.Bill.Status != BillStatus.Draft && bli.Bill.Status != BillStatus.Voided && bli.Bill.BillDate <= asOfEnd)
.Where(bli => bli.Bill.Status != BillStatus.Draft && bli.Bill.Status != BillStatus.Voided && bli.Bill.BillDate <= asOfEnd)
.SumAsync(bli => (decimal?)bli.Amount) ?? 0;
// JE net effect on revenue accounts (positive = additional revenue recognised via manual JE)
@@ -511,21 +376,20 @@ public class FinancialReportService : IFinancialReportService
// GC items sold via invoices are reclassified to GC Liability and not yet earned income.
var lifetimeGcReclassified = await _context.InvoiceItems
.Where(ii => ii.CompanyId == companyId && ii.IsGiftCertificate
.Where(ii => ii.IsGiftCertificate
&& ii.Invoice.Status != InvoiceStatus.Draft
&& ii.Invoice.Status != InvoiceStatus.Voided
&& ii.Invoice.InvoiceDate <= asOfEnd)
.SumAsync(ii => (decimal?)ii.TotalPrice) ?? 0m;
// Voided GCs with remaining balance become breakage income (the liability is extinguished).
var lifetimeGcBreakage = await _context.GiftCertificates
.Where(gc => gc.CompanyId == companyId && !gc.IsDeleted && gc.Status == GiftCertificateStatus.Voided
.Where(gc => !gc.IsDeleted && gc.Status == GiftCertificateStatus.Voided
&& gc.UpdatedAt <= asOfEnd && gc.OriginalAmount > gc.RedeemedAmount)
.SumAsync(gc => (decimal?)(gc.OriginalAmount - gc.RedeemedAmount)) ?? 0m;
var retainedEarnings = lifetimeRevenue + jeRevNet
- lifetimeDiscounts
- lifetimeCreditMemos
- refundReturnsTotalBs // revenue portion of cash refunds (reversed sales)
- lifetimeGcReclassified // deferred to GC Liability, not earned yet
+ lifetimeGcBreakage // breakage income when GC voided with balance
- lifetimeDirectExp
@@ -533,7 +397,7 @@ public class FinancialReportService : IFinancialReportService
- jeExpNet;
var accounts = await _context.Accounts
.Where(a => a.CompanyId == companyId && a.IsActive)
.Where(a => a.IsActive)
.OrderBy(a => a.AccountNumber)
.ToListAsync();
@@ -561,17 +425,11 @@ public class FinancialReportService : IFinancialReportService
credits += taxByAcct.GetValueOrDefault(a.Id);
credits += refundsByAcctBs.GetValueOrDefault(a.Id); // refunds reduce bank balance
debits += depositsByAcctDepBs.GetValueOrDefault(a.Id); // deposits increase bank balance
debits += refundTaxByAcctBs.GetValueOrDefault(a.Id); // refund tax portion relieves the tax liability
if (gcLiabilityAcctIdBs.HasValue && a.Id == gcLiabilityAcctIdBs.Value)
{
credits += gcLiabilityCreditsBs; // GC issued → CR liability
debits += gcLiabilityDebitsBs; // redeemed/voided → DR liability
}
if (customerCreditsAcctIdBs.HasValue && a.Id == customerCreditsAcctIdBs.Value)
{
credits += cmIssuedNonVoidedBs; // credit memos issued → CR liability
debits += cmAppliedNonVoidedBs; // applied → DR liability
}
if (custDepositsAcctIdBs.HasValue && a.Id == custDepositsAcctIdBs.Value)
{
credits += custDepositsCreditsBs; // deposits taken → CR liability
@@ -641,8 +499,7 @@ public class FinancialReportService : IFinancialReportService
var openInvoices = await _context.Invoices
.Include(i => i.Customer)
.Where(i => i.CompanyId == companyId
&& i.Status != InvoiceStatus.Draft
.Where(i => i.Status != InvoiceStatus.Draft
&& i.Status != InvoiceStatus.Voided
&& i.Status != InvoiceStatus.Paid
&& i.InvoiceDate <= asOfEnd
@@ -722,15 +579,14 @@ public class FinancialReportService : IFinancialReportService
var invoices = await _context.Invoices
.Include(i => i.Customer)
.Include(i => i.Payments)
.Where(i => i.CompanyId == companyId
&& i.Status != InvoiceStatus.Draft
.Where(i => i.Status != InvoiceStatus.Draft
&& i.Status != InvoiceStatus.Voided
&& i.InvoiceDate >= from && i.InvoiceDate <= toEnd)
.OrderBy(i => i.InvoiceDate)
.ToListAsync();
var collectedInPeriod = await _context.Payments
.Where(p => p.CompanyId == companyId && p.PaymentDate >= from && p.PaymentDate <= toEnd)
.Where(p => p.PaymentDate >= from && p.PaymentDate <= toEnd)
.SumAsync(p => (decimal?)p.Amount) ?? 0;
var byCustomer = invoices
@@ -995,8 +851,9 @@ public class FinancialReportService : IFinancialReportService
// Bank/cash: customer payments deposited here (DR)
var depositsByAcct = await _context.Payments
.Where(p => p.CompanyId == companyId && p.PaymentDate <= asOfEnd && p.DepositAccountId != null
&& p.Invoice.Status != InvoiceStatus.Voided)
.Where(p => p.PaymentDate <= asOfEnd && p.DepositAccountId != null
&& p.Invoice.Status != InvoiceStatus.Voided
&& p.Invoice.Status != InvoiceStatus.WrittenOff)
.GroupBy(p => p.DepositAccountId!.Value)
.Select(g => new { Id = g.Key, Amt = g.Sum(p => p.Amount) })
.ToDictionaryAsync(g => g.Id, g => g.Amt);
@@ -1004,42 +861,42 @@ public class FinancialReportService : IFinancialReportService
// AP: vendor credit applications reduce AP (DR) — credits are applied when a vendor
// issues a credit note and it is matched against a specific bill.
var vcByApAcct = await _context.VendorCreditApplications
.Where(vca => vca.CompanyId == companyId && vca.AppliedDate <= asOfEnd)
.Where(vca => vca.AppliedDate <= asOfEnd)
.GroupBy(vca => vca.VendorCredit.APAccountId)
.Select(g => new { Id = g.Key, Amt = g.Sum(vca => vca.Amount) })
.ToDictionaryAsync(g => g.Id, g => g.Amt);
// Bank/cash: expenses paid from here (CR)
var expFromByAcct = await _context.Expenses
.Where(e => e.CompanyId == companyId && e.Date <= asOfEnd)
.Where(e => e.Date <= asOfEnd)
.GroupBy(e => e.PaymentAccountId)
.Select(g => new { Id = g.Key, Amt = g.Sum(e => e.Amount) })
.ToDictionaryAsync(g => g.Id, g => g.Amt);
// Bank/cash: bill payments made from here (CR)
var bpFromByAcct = await _context.BillPayments
.Where(bp => bp.CompanyId == companyId && bp.PaymentDate <= asOfEnd)
.Where(bp => bp.PaymentDate <= asOfEnd)
.GroupBy(bp => bp.BankAccountId)
.Select(g => new { Id = g.Key, Amt = g.Sum(bp => bp.Amount) })
.ToDictionaryAsync(g => g.Id, g => g.Amt);
// AP: bills increase AP (CR)
var billsByApAcct = await _context.Bills
.Where(b => b.CompanyId == companyId && b.Status != BillStatus.Draft && b.Status != BillStatus.Voided && b.BillDate <= asOfEnd)
.Where(b => b.Status != BillStatus.Draft && b.Status != BillStatus.Voided && b.BillDate <= asOfEnd)
.GroupBy(b => b.APAccountId)
.Select(g => new { Id = g.Key, Amt = g.Sum(b => b.Total) })
.ToDictionaryAsync(g => g.Id, g => g.Amt);
// AP: bill payments reduce AP (DR)
var bpByApAcct = await _context.BillPayments
.Where(bp => bp.CompanyId == companyId && bp.PaymentDate <= asOfEnd)
.Where(bp => bp.PaymentDate <= asOfEnd)
.GroupBy(bp => bp.Bill.APAccountId)
.Select(g => new { Id = g.Key, Amt = g.Sum(bp => bp.Amount) })
.ToDictionaryAsync(g => g.Id, g => g.Amt);
// Tax liability: sales tax collected (CR)
var taxByAcct = await _context.Invoices
.Where(i => i.CompanyId == companyId && i.SalesTaxAccountId != null && i.TaxAmount > 0
.Where(i => i.SalesTaxAccountId != null && i.TaxAmount > 0
&& i.Status != InvoiceStatus.Draft && i.Status != InvoiceStatus.Voided
&& i.InvoiceDate <= asOfEnd)
.GroupBy(i => i.SalesTaxAccountId!.Value)
@@ -1048,7 +905,7 @@ public class FinancialReportService : IFinancialReportService
// Revenue accounts: invoice line items (CR)
var revenueByAcct = await _context.InvoiceItems
.Where(ii => ii.CompanyId == companyId && ii.RevenueAccountId != null
.Where(ii => ii.RevenueAccountId != null
&& ii.Invoice.Status != InvoiceStatus.Draft
&& ii.Invoice.Status != InvoiceStatus.Voided
&& ii.Invoice.InvoiceDate <= asOfEnd)
@@ -1058,14 +915,14 @@ public class FinancialReportService : IFinancialReportService
// Expense accounts: direct expenses (DR)
var expenseByAcct = await _context.Expenses
.Where(e => e.CompanyId == companyId && e.Date <= asOfEnd)
.Where(e => e.Date <= asOfEnd)
.GroupBy(e => e.ExpenseAccountId)
.Select(g => new { Id = g.Key, Amt = g.Sum(e => e.Amount) })
.ToDictionaryAsync(g => g.Id, g => g.Amt);
// Expense/COGS accounts: vendor bill line items (DR)
var billLinesByAcct = await _context.BillLineItems
.Where(bli => bli.CompanyId == companyId && bli.AccountId != null
.Where(bli => bli.AccountId != null
&& bli.Bill.Status != BillStatus.Draft
&& bli.Bill.Status != BillStatus.Voided
&& bli.Bill.BillDate <= asOfEnd)
@@ -1073,25 +930,6 @@ public class FinancialReportService : IFinancialReportService
.Select(g => new { Id = g.Key, Amt = g.Sum(bli => bli.Amount) })
.ToDictionaryAsync(g => g.Id, g => g.Amt);
// Inventory consumption: COGS account (DR) and Inventory asset account (CR) for JobUsage/Waste
// transactions on items with both accounts mapped — mirrors the DR COGS / CR Inventory posting.
var cogsConsumptionByAcct = await _context.InventoryTransactions
.Where(t => t.CompanyId == companyId
&& (t.TransactionType == InventoryTransactionType.JobUsage || t.TransactionType == InventoryTransactionType.Waste)
&& t.InventoryItem.CogsAccountId != null && t.InventoryItem.InventoryAccountId != null
&& t.TransactionDate <= asOfEnd)
.GroupBy(t => t.InventoryItem.CogsAccountId!.Value)
.Select(g => new { Id = g.Key, Amt = g.Sum(t => t.TotalCost) })
.ToDictionaryAsync(g => g.Id, g => g.Amt);
var invConsumptionByAcct = await _context.InventoryTransactions
.Where(t => t.CompanyId == companyId
&& (t.TransactionType == InventoryTransactionType.JobUsage || t.TransactionType == InventoryTransactionType.Waste)
&& t.InventoryItem.CogsAccountId != null && t.InventoryItem.InventoryAccountId != null
&& t.TransactionDate <= asOfEnd)
.GroupBy(t => t.InventoryItem.InventoryAccountId!.Value)
.Select(g => new { Id = g.Key, Amt = g.Sum(t => t.TotalCost) })
.ToDictionaryAsync(g => g.Id, g => g.Amt);
// Sales Discounts contra-revenue account: invoice discounts and credit memo applications (DR).
// Both reduce net revenue and are attributed to account 4950 as contra-revenue debits.
// Credit memo applications are also added to AR credits below so the double-entry balances.
@@ -1106,50 +944,33 @@ public class FinancialReportService : IFinancialReportService
.FirstOrDefaultAsync();
var cmApplied = await _context.CreditMemoApplications
.Where(a => a.CompanyId == companyId && a.AppliedDate <= asOfEnd
.Where(a => a.AppliedDate <= asOfEnd
&& a.Invoice.Status != InvoiceStatus.Voided)
.SumAsync(a => (decimal?)a.AmountApplied) ?? 0m;
// Customer Credits (2350) model: a credit memo books DR Sales Discounts / CR Customer Credits on
// issue, then DR Customer Credits / CR AR on apply. So the 4950 contra-revenue is the *issued*
// amount (active memos in full + the applied portion of voided memos), and the 2350 liability is
// the unapplied balance on active memos. AR is still credited by applications (cmApplied).
var cmIssuedNonVoided = await _context.CreditMemos
.Where(m => m.CompanyId == companyId && m.Status != CreditMemoStatus.Voided && m.IssueDate <= asOfEnd)
.SumAsync(m => (decimal?)m.Amount) ?? 0m;
var cmAppliedNonVoided = await _context.CreditMemoApplications
.Where(a => a.CompanyId == companyId && a.AppliedDate <= asOfEnd
&& a.Invoice.Status != InvoiceStatus.Voided
&& a.CreditMemo.Status != CreditMemoStatus.Voided)
.SumAsync(a => (decimal?)a.AmountApplied) ?? 0m;
var cmContraRevenue = cmIssuedNonVoided + (cmApplied - cmAppliedNonVoided); // DR 4950
var customerCreditsAcctId = await _context.Accounts
.Where(a => a.CompanyId == companyId && a.AccountNumber == "2350" && a.IsActive && !a.IsDeleted)
.Select(a => (int?)a.Id).FirstOrDefaultAsync();
var discountsByAcct = new Dictionary<int, decimal>();
if (discountAcctId.HasValue)
{
var totalDiscounts = await _context.Invoices
.Where(i => i.CompanyId == companyId && i.DiscountAmount > 0
.Where(i => i.DiscountAmount > 0
&& i.Status != InvoiceStatus.Draft
&& i.Status != InvoiceStatus.Voided
&& i.InvoiceDate <= asOfEnd)
.SumAsync(i => (decimal?)i.DiscountAmount) ?? 0m;
if (totalDiscounts + cmContraRevenue > 0)
discountsByAcct[discountAcctId.Value] = totalDiscounts + cmContraRevenue;
if (totalDiscounts + cmApplied > 0)
discountsByAcct[discountAcctId.Value] = totalDiscounts + cmApplied;
}
// JE lines: posted entries debit/credit all account types
var jeDebitsByAcct = await _context.JournalEntryLines
.Where(l => l.CompanyId == companyId && l.JournalEntry.Status == JournalEntryStatus.Posted
.Where(l => l.JournalEntry.Status == JournalEntryStatus.Posted
&& l.JournalEntry.EntryDate <= asOfEnd)
.GroupBy(l => l.AccountId)
.Select(g => new { Id = g.Key, Amt = g.Sum(l => l.DebitAmount) })
.ToDictionaryAsync(g => g.Id, g => g.Amt);
var jeCreditsByAcct = await _context.JournalEntryLines
.Where(l => l.CompanyId == companyId && l.JournalEntry.Status == JournalEntryStatus.Posted
.Where(l => l.JournalEntry.Status == JournalEntryStatus.Posted
&& l.JournalEntry.EntryDate <= asOfEnd)
.GroupBy(l => l.AccountId)
.Select(g => new { Id = g.Key, Amt = g.Sum(l => l.CreditAmount) })
@@ -1159,48 +980,25 @@ public class FinancialReportService : IFinancialReportService
// Credits include both cash payments and credit memo applications (which reduce open AR
// when a customer credit is applied against a specific invoice).
var arTotalDebits = await _context.Invoices
.Where(i => i.CompanyId == companyId && i.Status != InvoiceStatus.Draft && i.Status != InvoiceStatus.Voided
.Where(i => i.Status != InvoiceStatus.Draft && i.Status != InvoiceStatus.Voided
&& i.InvoiceDate <= asOfEnd)
.SumAsync(i => (decimal?)i.Total) ?? 0m;
var arTotalCredits = await _context.Payments
.Where(p => p.CompanyId == companyId && p.PaymentDate <= asOfEnd
&& p.Invoice.Status != InvoiceStatus.Voided)
.Where(p => p.PaymentDate <= asOfEnd
&& p.Invoice.Status != InvoiceStatus.Voided
&& p.Invoice.Status != InvoiceStatus.WrittenOff)
.SumAsync(p => (decimal?)p.Amount) ?? 0m;
arTotalCredits += cmApplied; // credit memo applications reduce AR balance
// Gift-certificate redemptions credit AR too (DR 2500 / CR AR). Without this the redemption's
// 2500 debit is recomputed but its AR credit is not, leaving the trial balance out of balance.
var gcRedeemedTb = await _context.GiftCertificateRedemptions
.Where(r => r.CompanyId == companyId && !r.IsDeleted && r.RedeemedDate <= asOfEnd
&& r.Invoice.Status != InvoiceStatus.Voided)
.SumAsync(r => (decimal?)r.AmountRedeemed) ?? 0m;
arTotalCredits += gcRedeemedTb;
// Cash refunds reverse the sale: revenue portion → DR Sales Returns (4960), tax portion →
// DR Sales Tax Payable (relieves the liability), cash → CR bank (refundsByAcct below). They no
// longer touch AR. Store-credit refunds post via CreditMemo, not the GL, so are excluded.
var saleReversingRefunds = await _context.Refunds
.Where(r => r.CompanyId == companyId && r.RefundDate <= asOfEnd && !r.IsDeleted && r.Invoice != null
&& r.RefundMethod != PaymentMethod.StoreCredit)
.Select(r => new { r.Amount, r.Invoice!.TaxAmount, r.Invoice.Total, r.Invoice.SalesTaxAccountId })
.ToListAsync();
decimal refundReturnsTotal = 0m;
var refundTaxByAcct = new Dictionary<int, decimal>();
foreach (var r in saleReversingRefunds)
{
var (returnsPortion, taxPortion) = RefundAllocation.Split(r.Amount, r.TaxAmount, r.Total);
refundReturnsTotal += returnsPortion;
if (taxPortion != 0m && r.SalesTaxAccountId.HasValue)
refundTaxByAcct[r.SalesTaxAccountId.Value] = refundTaxByAcct.GetValueOrDefault(r.SalesTaxAccountId.Value) + taxPortion;
}
var salesReturnsAcctId = await _context.Accounts
.Where(a => a.CompanyId == companyId && a.AccountNumber == "4960" && a.IsActive && !a.IsDeleted)
.Select(a => (int?)a.Id).FirstOrDefaultAsync();
// Refunds reverse collected payments — reduce net AR credits (re-opens the receivable).
var refundTotal = await _context.Refunds
.Where(r => r.RefundDate <= asOfEnd && !r.IsDeleted)
.SumAsync(r => (decimal?)r.Amount) ?? 0m;
arTotalCredits -= refundTotal;
// Refunds by bank account: money leaving the account (CR to checking/bank).
var refundsByAcct = await _context.Refunds
.Where(r => r.CompanyId == companyId && r.RefundDate <= asOfEnd && !r.IsDeleted && r.DepositAccountId != null)
.Where(r => r.RefundDate <= asOfEnd && !r.IsDeleted && r.DepositAccountId != null)
.GroupBy(r => r.DepositAccountId!.Value)
.Select(g => new { Id = g.Key, Amt = g.Sum(r => r.Amount) })
.ToDictionaryAsync(g => g.Id, g => g.Amt);
@@ -1208,7 +1006,7 @@ public class FinancialReportService : IFinancialReportService
// Deposits by bank account: cash received at deposit recording time (DR bank).
// Deposit-sourced Payments have DepositAccountId = null, so there is no double-count with depositsByAcct.
var depositsByAcctDep = await _context.Deposits
.Where(d => d.CompanyId == companyId && !d.IsDeleted && d.DepositAccountId != null && d.ReceivedDate <= asOfEnd)
.Where(d => !d.IsDeleted && d.DepositAccountId != null && d.ReceivedDate <= asOfEnd)
.GroupBy(d => d.DepositAccountId!.Value)
.Select(g => new { Id = g.Key, Amt = g.Sum(d => d.Amount) })
.ToDictionaryAsync(g => g.Id, g => g.Amt);
@@ -1219,11 +1017,11 @@ public class FinancialReportService : IFinancialReportService
.Select(a => (int?)a.Id).FirstOrDefaultAsync();
var custDepositsCredits = custDepositsAcctId.HasValue
? (await _context.Deposits
.Where(d => d.CompanyId == companyId && !d.IsDeleted && d.ReceivedDate <= asOfEnd)
.Where(d => !d.IsDeleted && d.ReceivedDate <= asOfEnd)
.SumAsync(d => (decimal?)d.Amount) ?? 0m) : 0m;
var custDepositsDebits = custDepositsAcctId.HasValue
? (await _context.Deposits
.Where(d => d.CompanyId == companyId && !d.IsDeleted && d.AppliedToInvoiceId != null && d.AppliedDate <= asOfEnd)
.Where(d => !d.IsDeleted && d.AppliedToInvoiceId != null && d.AppliedDate <= asOfEnd)
.SumAsync(d => (decimal?)d.Amount) ?? 0m) : 0m;
// Gift Certificate Liability (2500): balance driven by GC issuances, redemptions, and voids.
@@ -1232,14 +1030,14 @@ public class FinancialReportService : IFinancialReportService
.Select(a => (int?)a.Id).FirstOrDefaultAsync();
var gcLiabilityCredits = gcLiabilityAcctId.HasValue
? (await _context.GiftCertificates
.Where(gc => gc.CompanyId == companyId && !gc.IsDeleted && gc.IssueDate <= asOfEnd)
.Where(gc => !gc.IsDeleted && gc.IssueDate <= asOfEnd)
.SumAsync(gc => (decimal?)gc.OriginalAmount) ?? 0m) : 0m;
var gcLiabilityDebits = gcLiabilityAcctId.HasValue
? ((await _context.GiftCertificateRedemptions
.Where(r => r.CompanyId == companyId && !r.IsDeleted && r.RedeemedDate <= asOfEnd)
.Where(r => !r.IsDeleted && r.RedeemedDate <= asOfEnd)
.SumAsync(r => (decimal?)r.AmountRedeemed) ?? 0m)
+ (await _context.GiftCertificates
.Where(gc => gc.CompanyId == companyId && !gc.IsDeleted && gc.Status == GiftCertificateStatus.Voided
.Where(gc => !gc.IsDeleted && gc.Status == GiftCertificateStatus.Voided
&& gc.UpdatedAt <= asOfEnd && gc.OriginalAmount > gc.RedeemedAmount)
.SumAsync(gc => (decimal?)(gc.OriginalAmount - gc.RedeemedAmount)) ?? 0m)) : 0m;
@@ -1279,13 +1077,8 @@ public class FinancialReportService : IFinancialReportService
debits += expenseByAcct.GetValueOrDefault(a.Id);
debits += billLinesByAcct.GetValueOrDefault(a.Id);
debits += discountsByAcct.GetValueOrDefault(a.Id);
debits += cogsConsumptionByAcct.GetValueOrDefault(a.Id); // inventory consumption → DR COGS
credits += invConsumptionByAcct.GetValueOrDefault(a.Id); // inventory consumption → CR Inventory
credits += refundsByAcct.GetValueOrDefault(a.Id); // refunds reduce bank balance
debits += depositsByAcctDep.GetValueOrDefault(a.Id); // deposits increase bank balance
if (salesReturnsAcctId.HasValue && a.Id == salesReturnsAcctId.Value)
debits += refundReturnsTotal; // revenue portion of cash refunds
debits += refundTaxByAcct.GetValueOrDefault(a.Id); // tax portion relieves the tax liability
if (gcLiabilityAcctId.HasValue && a.Id == gcLiabilityAcctId.Value)
{
credits += gcLiabilityCredits; // GC issued → CR liability
@@ -1296,11 +1089,6 @@ public class FinancialReportService : IFinancialReportService
credits += custDepositsCredits; // deposits taken → CR liability
debits += custDepositsDebits; // deposits applied → DR liability
}
if (customerCreditsAcctId.HasValue && a.Id == customerCreditsAcctId.Value)
{
credits += cmIssuedNonVoided; // credit memos issued → CR liability
debits += cmAppliedNonVoided; // applied → DR liability
}
}
// Manual JEs apply to all account types (including AR/AP for unusual adjustments)
@@ -1387,17 +1175,17 @@ public class FinancialReportService : IFinancialReportService
// Opening balance: invoiced paid before period start
var preInvoiced = await _context.Invoices
.Where(i => i.CompanyId == companyId && i.CustomerId == customerId
.Where(i => i.CustomerId == customerId
&& i.Status != InvoiceStatus.Draft && i.Status != InvoiceStatus.Voided
&& i.InvoiceDate < from)
.SumAsync(i => (decimal?)i.Total) ?? 0;
var prePaid = await _context.Payments
.Where(p => p.CompanyId == companyId && p.Invoice.CustomerId == customerId
.Where(p => p.Invoice.CustomerId == customerId
&& p.Invoice.Status != InvoiceStatus.Voided
&& p.PaymentDate < from)
.SumAsync(p => (decimal?)p.Amount) ?? 0;
var preCredits = await _context.CreditMemoApplications
.Where(a => a.CompanyId == companyId && a.Invoice.CustomerId == customerId && a.AppliedDate < from)
.Where(a => a.Invoice.CustomerId == customerId && a.AppliedDate < from)
.SumAsync(a => (decimal?)a.AmountApplied) ?? 0;
var openingBalance = preInvoiced - prePaid - preCredits;
@@ -1406,7 +1194,7 @@ public class FinancialReportService : IFinancialReportService
var lines = new List<StatementLineDto>();
var periodInvoices = await _context.Invoices
.Where(i => i.CompanyId == companyId && i.CustomerId == customerId
.Where(i => i.CustomerId == customerId
&& i.Status != InvoiceStatus.Draft && i.Status != InvoiceStatus.Voided
&& i.InvoiceDate >= from && i.InvoiceDate <= toEnd)
.AsNoTracking().ToListAsync();
@@ -1423,7 +1211,7 @@ public class FinancialReportService : IFinancialReportService
var periodPayments = await _context.Payments
.Include(p => p.Invoice)
.Where(p => p.CompanyId == companyId && p.Invoice.CustomerId == customerId
.Where(p => p.Invoice.CustomerId == customerId
&& p.Invoice.Status != InvoiceStatus.Voided
&& p.PaymentDate >= from && p.PaymentDate <= toEnd)
.AsNoTracking().ToListAsync();
@@ -1441,7 +1229,7 @@ public class FinancialReportService : IFinancialReportService
var periodCredits = await _context.CreditMemoApplications
.Include(a => a.Invoice)
.Include(a => a.CreditMemo)
.Where(a => a.CompanyId == companyId && a.Invoice.CustomerId == customerId
.Where(a => a.Invoice.CustomerId == customerId
&& a.AppliedDate >= from && a.AppliedDate <= toEnd)
.AsNoTracking().ToListAsync();
@@ -1492,15 +1280,15 @@ public class FinancialReportService : IFinancialReportService
// Opening balance: bills payments credits before period start
var preBills = await _context.Bills
.Where(b => b.CompanyId == companyId && b.VendorId == vendorId
.Where(b => b.VendorId == vendorId
&& b.Status != BillStatus.Draft && b.Status != BillStatus.Voided
&& b.BillDate < from)
.SumAsync(b => (decimal?)b.Total) ?? 0;
var prePayments = await _context.BillPayments
.Where(bp => bp.CompanyId == companyId && bp.Bill.VendorId == vendorId && bp.PaymentDate < from)
.Where(bp => bp.Bill.VendorId == vendorId && bp.PaymentDate < from)
.SumAsync(bp => (decimal?)bp.Amount) ?? 0;
var preVcApplied = await _context.VendorCreditApplications
.Where(vca => vca.CompanyId == companyId && vca.Bill.VendorId == vendorId && vca.AppliedDate < from)
.Where(vca => vca.Bill.VendorId == vendorId && vca.AppliedDate < from)
.SumAsync(vca => (decimal?)vca.Amount) ?? 0;
var openingBalance = preBills - prePayments - preVcApplied;
@@ -1508,7 +1296,7 @@ public class FinancialReportService : IFinancialReportService
var lines = new List<StatementLineDto>();
var periodBills = await _context.Bills
.Where(b => b.CompanyId == companyId && b.VendorId == vendorId
.Where(b => b.VendorId == vendorId
&& b.Status != BillStatus.Draft && b.Status != BillStatus.Voided
&& b.BillDate >= from && b.BillDate <= toEnd)
.AsNoTracking().ToListAsync();
@@ -1525,7 +1313,7 @@ public class FinancialReportService : IFinancialReportService
var periodPayments = await _context.BillPayments
.Include(bp => bp.Bill)
.Where(bp => bp.CompanyId == companyId && bp.Bill.VendorId == vendorId
.Where(bp => bp.Bill.VendorId == vendorId
&& bp.PaymentDate >= from && bp.PaymentDate <= toEnd)
.AsNoTracking().ToListAsync();
@@ -1542,7 +1330,7 @@ public class FinancialReportService : IFinancialReportService
var periodVcApplied = await _context.VendorCreditApplications
.Include(vca => vca.VendorCredit)
.Include(vca => vca.Bill)
.Where(vca => vca.CompanyId == companyId && vca.Bill.VendorId == vendorId
.Where(vca => vca.Bill.VendorId == vendorId
&& vca.AppliedDate >= from && vca.AppliedDate <= toEnd)
.AsNoTracking().ToListAsync();
@@ -2,7 +2,6 @@ using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using PowderCoating.Application.DTOs.Accounting;
using PowderCoating.Application.Interfaces;
using PowderCoating.Core.Accounting;
using PowderCoating.Core.Enums;
using PowderCoating.Infrastructure.Data;
@@ -200,43 +199,6 @@ public class LedgerService : ILedgerService
LinkId = inv.Id
});
// ── 5b. Cash refunds reverse the sale (DR Sales Returns 4960 + DR Sales Tax Payable) ──
// The revenue portion debits Sales Returns; the tax portion debits the invoice's sales-tax
// account (relieving the liability). Cash leaving the bank is handled in the bank section above.
// Store-credit refunds are excluded — they post via CreditMemo, not the GL (see CancelRefund).
if (account.AccountNumber == "4960" || account.AccountType == AccountType.Liability)
{
var saleReversingRefunds = await _context.Refunds
.Include(r => r.Invoice)
.Where(r => !r.IsDeleted && r.Invoice != null
&& r.RefundMethod != PaymentMethod.StoreCredit
&& r.RefundDate >= fromDate && r.RefundDate <= toDate)
.ToListAsync();
foreach (var r in saleReversingRefunds)
{
var (returnsPortion, taxPortion) = RefundAllocation.Split(r.Amount, r.Invoice.TaxAmount, r.Invoice.Total);
if (account.AccountNumber == "4960" && returnsPortion != 0)
entries.Add(new LedgerEntryDto
{
Date = r.RefundDate, Reference = r.Reference ?? $"REF-{r.Id}", Source = "Refund",
Description = $"Sales return — {r.Invoice.InvoiceNumber}",
Debit = returnsPortion, Credit = 0,
LinkController = "Invoices", LinkId = r.InvoiceId
});
if (r.Invoice.SalesTaxAccountId == accountId && taxPortion != 0)
entries.Add(new LedgerEntryDto
{
Date = r.RefundDate, Reference = r.Reference ?? $"REF-{r.Id}", Source = "Refund",
Description = $"Tax refunded — {r.Invoice.InvoiceNumber}",
Debit = taxPortion, Credit = 0,
LinkController = "Invoices", LinkId = r.InvoiceId
});
}
}
// ── 6. Direct expenses categorized to this account (DEBIT) ────────────
// e.g. Expense account 6200 receives direct expense entries
var expensesTo = await _context.Expenses
@@ -350,29 +312,24 @@ public class LedgerService : ILedgerService
LinkId = cm.InvoiceId
});
// Gift-certificate redemptions reduce open AR (CREDIT)ApplyGiftCertificate posts DR 2500 / CR AR.
var arGcRedemptions = await _context.GiftCertificateRedemptions
// Refunds re-open AR (DEBIT — customer owes again after refund)
var arRefunds = await _context.Refunds
.Include(r => r.Invoice)
.Include(r => r.GiftCertificate)
.Where(r => !r.IsDeleted && r.RedeemedDate >= fromDate && r.RedeemedDate <= toDate
&& r.Invoice.Status != InvoiceStatus.Voided)
.Where(r => r.RefundDate >= fromDate && r.RefundDate <= toDate && !r.IsDeleted)
.ToListAsync();
foreach (var r in arGcRedemptions)
foreach (var r in arRefunds)
entries.Add(new LedgerEntryDto
{
Date = r.RedeemedDate,
Reference = r.GiftCertificate?.CertificateCode ?? $"GC-{r.GiftCertificateId}",
Source = "Gift Certificate",
Description = $"GC redeemed on {r.Invoice?.InvoiceNumber}",
Debit = 0,
Credit = r.AmountRedeemed,
Date = r.RefundDate,
Reference = r.Reference ?? $"REF-{r.Id}",
Source = "Refund",
Description = r.Reason,
Debit = r.Amount,
Credit = 0,
LinkController = "Invoices",
LinkId = r.InvoiceId
});
// NOTE: cash refunds no longer touch AR. Under the "reverse the sale" model they debit
// Sales Returns + Sales Tax Payable and credit the bank (see section 5b above).
}
// ── 9. Accounts Payable ────────────────────────────────────────────────
@@ -516,125 +473,6 @@ public class LedgerService : ILedgerService
});
}
// ── 12b. Customer Credits liability (account 2350) ────────────────────
// CR when a credit memo (incl. store-credit refund) is issued; DR when applied to an invoice.
// Voided memos are excluded (their issue/void net to zero).
if (account.AccountNumber == "2350")
{
var memosIssued = await _context.CreditMemos
.Where(m => m.Status != CreditMemoStatus.Voided
&& m.IssueDate >= fromDate && m.IssueDate <= toDate)
.ToListAsync();
foreach (var m in memosIssued)
entries.Add(new LedgerEntryDto
{
Date = m.IssueDate, Reference = m.MemoNumber,
Source = "Credit Memo", Description = "Store credit issued",
Debit = 0, Credit = m.Amount,
LinkController = "CreditMemos", LinkId = m.Id
});
var memosApplied = await _context.CreditMemoApplications
.Include(a => a.CreditMemo).Include(a => a.Invoice)
.Where(a => a.CreditMemo.Status != CreditMemoStatus.Voided
&& a.AppliedDate >= fromDate && a.AppliedDate <= toDate)
.ToListAsync();
foreach (var a in memosApplied)
entries.Add(new LedgerEntryDto
{
Date = a.AppliedDate, Reference = a.CreditMemo?.MemoNumber ?? $"CM-{a.CreditMemoId}",
Source = "Credit Applied", Description = $"Applied to {a.Invoice?.InvoiceNumber}",
Debit = a.AmountApplied, Credit = 0,
LinkController = "Invoices", LinkId = a.InvoiceId
});
}
// ── 12c. Sales Discounts contra-revenue (account 4950) ────────────────
// Mirrors the actual postings made by AccountBalanceService so a balance recompute reproduces
// the stored CurrentBalance (otherwise "Recalculate Balances" would wipe 4950 down to JE-only):
// • Invoice discounts → DR 4950 at invoice date (InvoicesController invoice create/edit).
// • Credit memo issuance → DR 4950 = full memo amount at issue (CreditMemosController.Create
// and the store-credit refund path, which both create a CreditMemo row).
// • Credit memo void → CR 4950 = unapplied remainder at void (reverses the unused part).
// Keep this in step with FinancialReportService's 4950 computation (discountsByAcct + cmContraRevenue).
if (account.AccountNumber == "4950")
{
var discountInvoices = await _context.Invoices
.Where(i => i.DiscountAmount > 0
&& i.Status != InvoiceStatus.Draft && i.Status != InvoiceStatus.Voided
&& i.InvoiceDate >= fromDate && i.InvoiceDate <= toDate)
.ToListAsync();
foreach (var inv in discountInvoices)
entries.Add(new LedgerEntryDto
{
Date = inv.InvoiceDate, Reference = inv.InvoiceNumber,
Source = "Invoice", Description = $"Discount on {inv.InvoiceNumber}",
Debit = inv.DiscountAmount, Credit = 0,
LinkController = "Invoices", LinkId = inv.Id
});
var discountMemosIssued = await _context.CreditMemos
.Where(m => m.IssueDate >= fromDate && m.IssueDate <= toDate)
.ToListAsync();
foreach (var m in discountMemosIssued)
entries.Add(new LedgerEntryDto
{
Date = m.IssueDate, Reference = m.MemoNumber,
Source = "Credit Memo", Description = "Store credit issued (contra-revenue)",
Debit = m.Amount, Credit = 0,
LinkController = "CreditMemos", LinkId = m.Id
});
var discountMemosVoided = await _context.CreditMemos
.Where(m => m.Status == CreditMemoStatus.Voided
&& m.UpdatedAt >= fromDate && m.UpdatedAt <= toDate
&& m.Amount > m.AmountApplied)
.ToListAsync();
foreach (var m in discountMemosVoided)
entries.Add(new LedgerEntryDto
{
Date = m.UpdatedAt.GetValueOrDefault(), Reference = m.MemoNumber,
Source = "Credit Memo Voided", Description = "Reversed unapplied store credit",
Debit = 0, Credit = m.Amount - m.AmountApplied,
LinkController = "CreditMemos", LinkId = m.Id
});
}
// ── 12d. Inventory consumption COGS (DR COGS / CR Inventory) ──────────
// When an item with both a COGS and an Inventory account is consumed (JobUsage/Waste — the only
// two transaction types created at the COGS-posting sites), JobsController/InventoryController post
// DR COGS / CR Inventory at the transaction's TotalCost. Reproduce it here so a balance recompute
// matches the posting and the trial balance stays balanced. TotalCost is stored positive.
if (account.AccountType == AccountType.CostOfGoods || account.AccountSubType == AccountSubType.Inventory)
{
var consumption = await _context.InventoryTransactions
.Include(t => t.InventoryItem)
.Where(t => (t.TransactionType == InventoryTransactionType.JobUsage || t.TransactionType == InventoryTransactionType.Waste)
&& t.InventoryItem.CogsAccountId != null && t.InventoryItem.InventoryAccountId != null
&& (t.InventoryItem.CogsAccountId == accountId || t.InventoryItem.InventoryAccountId == accountId)
&& t.TransactionDate >= fromDate && t.TransactionDate <= toDate)
.ToListAsync();
foreach (var t in consumption)
{
var amount = Math.Abs(t.TotalCost);
if (t.InventoryItem.CogsAccountId == accountId)
entries.Add(new LedgerEntryDto
{
Date = t.TransactionDate, Reference = t.Reference ?? $"INV-{t.Id}",
Source = "Inventory Usage", Description = $"COGS — {t.InventoryItem.Name}",
Debit = amount, Credit = 0, LinkController = "Inventory", LinkId = t.InventoryItemId
});
if (t.InventoryItem.InventoryAccountId == accountId)
entries.Add(new LedgerEntryDto
{
Date = t.TransactionDate, Reference = t.Reference ?? $"INV-{t.Id}",
Source = "Inventory Usage", Description = $"Inventory relieved — {t.InventoryItem.Name}",
Debit = 0, Credit = amount, LinkController = "Inventory", LinkId = t.InventoryItemId
});
}
}
// ── 10. Journal Entry lines touching this account ──────────────────
var jeLines = await _context.JournalEntryLines
.Include(l => l.JournalEntry)
@@ -756,27 +594,6 @@ public class LedgerService : ILedgerService
&& i.InvoiceDate < beforeDate)
.SumAsync(i => (decimal?)i.TaxAmount) ?? 0;
// 5b. Cash refunds reverse the sale (DR Sales Returns 4960 + DR Sales Tax Payable). Store-credit
// refunds are excluded (no GL posting). Mirrors section 5b in GetAccountLedgerAsync.
if (account.AccountNumber == "4960" || account.AccountType == AccountType.Liability)
{
var priorRefunds = await _context.Refunds
.Include(r => r.Invoice)
.Where(r => !r.IsDeleted && r.Invoice != null
&& r.RefundMethod != PaymentMethod.StoreCredit
&& r.RefundDate < beforeDate)
.ToListAsync();
foreach (var r in priorRefunds)
{
var (returnsPortion, taxPortion) = RefundAllocation.Split(r.Amount, r.Invoice.TaxAmount, r.Invoice.Total);
if (account.AccountNumber == "4960")
debits += returnsPortion;
if (r.Invoice.SalesTaxAccountId == accountId)
debits += taxPortion;
}
}
// 6. Direct expenses categorized to this account (DEBIT)
debits += await _context.Expenses
.Where(e => e.ExpenseAccountId == accountId && e.Date < beforeDate)
@@ -807,13 +624,9 @@ public class LedgerService : ILedgerService
.Where(a => a.AppliedDate < beforeDate && a.Invoice.Status != InvoiceStatus.Voided)
.SumAsync(a => (decimal?)a.AmountApplied) ?? 0;
// Gift-certificate redemptions credit AR (DR 2500 / CR AR), same as in GetAccountLedgerAsync.
credits += await _context.GiftCertificateRedemptions
.Where(r => !r.IsDeleted && r.RedeemedDate < beforeDate && r.Invoice.Status != InvoiceStatus.Voided)
.SumAsync(r => (decimal?)r.AmountRedeemed) ?? 0;
// NOTE: cash refunds no longer debit AR — they reverse the sale (Sales Returns + Sales Tax),
// handled in section 5b above.
debits += await _context.Refunds
.Where(r => !r.IsDeleted && r.RefundDate < beforeDate)
.SumAsync(r => (decimal?)r.Amount) ?? 0;
}
// 9. Accounts Payable
@@ -861,55 +674,6 @@ public class LedgerService : ILedgerService
.SumAsync(d => (decimal?)d.Amount) ?? 0;
}
// 12b. Customer Credits liability (account 2350)
if (account.AccountNumber == "2350")
{
credits += await _context.CreditMemos
.Where(m => m.Status != CreditMemoStatus.Voided && m.IssueDate < beforeDate)
.SumAsync(m => (decimal?)m.Amount) ?? 0;
debits += await _context.CreditMemoApplications
.Where(a => a.CreditMemo.Status != CreditMemoStatus.Voided && a.AppliedDate < beforeDate)
.SumAsync(a => (decimal?)a.AmountApplied) ?? 0;
}
// 12c. Sales Discounts contra-revenue (account 4950). Mirrors section 12c in GetAccountLedgerAsync
// so the prior-period opening balance matches the actual postings (invoice discounts + memo issues,
// less the unapplied remainder of voided memos).
if (account.AccountNumber == "4950")
{
debits += await _context.Invoices
.Where(i => i.DiscountAmount > 0
&& i.Status != InvoiceStatus.Draft && i.Status != InvoiceStatus.Voided
&& i.InvoiceDate < beforeDate)
.SumAsync(i => (decimal?)i.DiscountAmount) ?? 0;
debits += await _context.CreditMemos
.Where(m => m.IssueDate < beforeDate)
.SumAsync(m => (decimal?)m.Amount) ?? 0;
credits += await _context.CreditMemos
.Where(m => m.Status == CreditMemoStatus.Voided && m.UpdatedAt < beforeDate && m.Amount > m.AmountApplied)
.SumAsync(m => (decimal?)(m.Amount - m.AmountApplied)) ?? 0;
}
// 12d. Inventory consumption COGS (DR COGS / CR Inventory). Mirrors section 12d in
// GetAccountLedgerAsync so the prior-period opening balance matches the posting.
if (account.AccountType == AccountType.CostOfGoods || account.AccountSubType == AccountSubType.Inventory)
{
var priorConsumption = await _context.InventoryTransactions
.Include(t => t.InventoryItem)
.Where(t => (t.TransactionType == InventoryTransactionType.JobUsage || t.TransactionType == InventoryTransactionType.Waste)
&& t.InventoryItem.CogsAccountId != null && t.InventoryItem.InventoryAccountId != null
&& (t.InventoryItem.CogsAccountId == accountId || t.InventoryItem.InventoryAccountId == accountId)
&& t.TransactionDate < beforeDate)
.ToListAsync();
foreach (var t in priorConsumption)
{
var amount = Math.Abs(t.TotalCost);
if (t.InventoryItem.CogsAccountId == accountId) debits += amount;
if (t.InventoryItem.InventoryAccountId == accountId) credits += amount;
}
}
// 10. Posted journal entry lines touching this account (prior to period)
debits += await _context.JournalEntryLines
.Where(l => l.AccountId == accountId
@@ -60,15 +60,7 @@ public partial class SeedDataService
new Account { AccountNumber = "2000", Name = "Accounts Payable", AccountType = AccountType.Liability, AccountSubType = AccountSubType.AccountsPayable, IsSystem = true, IsActive = true, Description = "Amounts owed to suppliers and vendors", CompanyId = company.Id, CreatedAt = now },
new Account { AccountNumber = "2100", Name = "Credit Card Payable", AccountType = AccountType.Liability, AccountSubType = AccountSubType.CreditCard, IsSystem = false, IsActive = true, Description = "Business credit card balance", CompanyId = company.Id, CreatedAt = now },
new Account { AccountNumber = "2200", Name = "Sales Tax Payable", AccountType = AccountType.Liability, AccountSubType = AccountSubType.OtherCurrentLiability, IsSystem = false, IsActive = true, Description = "Sales tax collected and owed to government", CompanyId = company.Id, CreatedAt = now },
// 2300 is the Customer Deposits liability — credited when a deposit is taken, debited when it is
// applied to an invoice (see DepositsController / InvoicesController, which resolve it by number).
// IsSystem because the GL posting code depends on it existing. Payroll lives at 2400 below.
new Account { AccountNumber = "2300", Name = "Customer Deposits", AccountType = AccountType.Liability, AccountSubType = AccountSubType.OtherCurrentLiability, IsSystem = true, IsActive = true, Description = "Deposits received from customers before an invoice is created; cleared when applied to an invoice", CompanyId = company.Id, CreatedAt = now },
new Account { AccountNumber = "2350", Name = "Customer Credits", AccountType = AccountType.Liability, AccountSubType = AccountSubType.OtherCurrentLiability, IsSystem = true, IsActive = true, Description = "Store credit owed to customers (credit memos not yet applied)", CompanyId = company.Id, CreatedAt = now },
new Account { AccountNumber = "2400", Name = "Payroll Liabilities", AccountType = AccountType.Liability, AccountSubType = AccountSubType.OtherCurrentLiability, IsSystem = false, IsActive = true, Description = "Payroll taxes and withholdings owed", CompanyId = company.Id, CreatedAt = now },
// 2500 Gift Certificate Liability — credited when a GC is issued, debited when redeemed/voided
// (resolved by number in GiftCertificatesController). IsSystem because the GL posting depends on it.
new Account { AccountNumber = "2500", Name = "Gift Certificate Liability", AccountType = AccountType.Liability, AccountSubType = AccountSubType.OtherCurrentLiability, IsSystem = true, IsActive = true, Description = "Outstanding gift certificate obligations owed to certificate holders", CompanyId = company.Id, CreatedAt = now },
new Account { AccountNumber = "2300", Name = "Payroll Liabilities", AccountType = AccountType.Liability, AccountSubType = AccountSubType.OtherCurrentLiability, IsSystem = false, IsActive = true, Description = "Payroll taxes and withholdings owed", CompanyId = company.Id, CreatedAt = now },
new Account { AccountNumber = "2900", Name = "Business Loan", AccountType = AccountType.Liability, AccountSubType = AccountSubType.LongTermLiability, IsSystem = false, IsActive = true, Description = "Long-term equipment or business loan", CompanyId = company.Id, CreatedAt = now },
// ── EQUITY ────────────────────────────────────────────────────────
@@ -85,7 +77,6 @@ public partial class SeedDataService
// A credit-normal account with a debit balance appears in the Trial Balance debit column,
// reducing net revenue to match the discounted AR amount that was posted.
new Account { AccountNumber = "4950", Name = "Sales Discounts", AccountType = AccountType.Revenue, AccountSubType = AccountSubType.OtherIncome, IsSystem = true, IsActive = true, Description = "Contra-revenue for invoice discounts granted to customers", CompanyId = company.Id, CreatedAt = now },
new Account { AccountNumber = "4960", Name = "Sales Returns & Allowances", AccountType = AccountType.Revenue, AccountSubType = AccountSubType.OtherIncome, IsSystem = true, IsActive = true, Description = "Contra-revenue for the revenue portion of customer refunds (reversed sales)", CompanyId = company.Id, CreatedAt = now },
// ── COST OF GOODS SOLD ────────────────────────────────────────────
new Account { AccountNumber = "5000", Name = "Cost of Goods Sold", AccountType = AccountType.CostOfGoods, AccountSubType = AccountSubType.CostOfGoodsSold, IsSystem = false, IsActive = true, Description = "Direct cost of services delivered", CompanyId = company.Id, CreatedAt = now },
@@ -150,134 +141,6 @@ public partial class SeedDataService
added++;
}
// 4960 Sales Returns & Allowances — contra-revenue account that receives the revenue portion
// of customer refunds under the "reverse the sale" model (DR Sales Returns + DR Sales Tax / CR Bank).
var has4960 = await _context.Set<Account>()
.IgnoreQueryFilters()
.AnyAsync(a => a.CompanyId == company.Id && a.AccountNumber == "4960" && !a.IsDeleted);
if (!has4960)
{
_context.Set<Account>().Add(new Account
{
AccountNumber = "4960",
Name = "Sales Returns & Allowances",
AccountType = AccountType.Revenue,
AccountSubType = AccountSubType.OtherIncome,
IsSystem = true,
IsActive = true,
Description = "Contra-revenue for the revenue portion of customer refunds (reversed sales)",
CompanyId = company.Id,
CreatedAt = now
});
await _context.SaveChangesAsync();
added++;
}
// 2350 Customer Credits — liability for store credit owed to customers. Credited when a credit
// memo (incl. store-credit refunds) is issued; debited when applied to an invoice.
var has2350 = await _context.Set<Account>()
.IgnoreQueryFilters()
.AnyAsync(a => a.CompanyId == company.Id && a.AccountNumber == "2350" && !a.IsDeleted);
if (!has2350)
{
_context.Set<Account>().Add(new Account
{
AccountNumber = "2350",
Name = "Customer Credits",
AccountType = AccountType.Liability,
AccountSubType = AccountSubType.OtherCurrentLiability,
IsSystem = true,
IsActive = true,
Description = "Store credit owed to customers (credit memos not yet applied)",
CompanyId = company.Id,
CreatedAt = now
});
await _context.SaveChangesAsync();
added++;
}
// 2300 used to be seeded as "Payroll Liabilities" but the deposit GL posting code has always
// resolved 2300 by number and used it as the Customer Deposits liability — so the account was
// mislabeled on the balance sheet. Rename it to "Customer Deposits" and mark it system. Only
// touch accounts still carrying the old default name so a user's own rename is preserved.
var legacyDepositsAcct = await _context.Set<Account>()
.IgnoreQueryFilters()
.FirstOrDefaultAsync(a => a.CompanyId == company.Id && a.AccountNumber == "2300"
&& !a.IsDeleted && a.Name == "Payroll Liabilities");
if (legacyDepositsAcct != null)
{
legacyDepositsAcct.Name = "Customer Deposits";
legacyDepositsAcct.Description = "Deposits received from customers before an invoice is created; cleared when applied to an invoice";
legacyDepositsAcct.IsSystem = true;
legacyDepositsAcct.UpdatedAt = now;
await _context.SaveChangesAsync();
}
// 2400 Payroll Liabilities — the payroll account displaced from 2300 (now Customer Deposits).
var has2400 = await _context.Set<Account>()
.IgnoreQueryFilters()
.AnyAsync(a => a.CompanyId == company.Id && a.AccountNumber == "2400" && !a.IsDeleted);
if (!has2400)
{
_context.Set<Account>().Add(new Account
{
AccountNumber = "2400",
Name = "Payroll Liabilities",
AccountType = AccountType.Liability,
AccountSubType = AccountSubType.OtherCurrentLiability,
IsSystem = false,
IsActive = true,
Description = "Payroll taxes and withholdings owed",
CompanyId = company.Id,
CreatedAt = now
});
await _context.SaveChangesAsync();
added++;
}
// 2500 has always been resolved by number as the Gift Certificate Liability (GiftCertificatesController),
// but the default-company seed created it as "Long-Term Loan" — so GC obligations were mislabeled there.
// Rename it (only where it still carries the old default name) and mark it system.
var legacyGcAcct = await _context.Set<Account>()
.IgnoreQueryFilters()
.FirstOrDefaultAsync(a => a.CompanyId == company.Id && a.AccountNumber == "2500"
&& !a.IsDeleted && a.Name == "Long-Term Loan");
if (legacyGcAcct != null)
{
legacyGcAcct.Name = "Gift Certificate Liability";
legacyGcAcct.Description = "Outstanding gift certificate obligations owed to certificate holders";
legacyGcAcct.IsSystem = true;
legacyGcAcct.UpdatedAt = now;
await _context.SaveChangesAsync();
}
// 2500 Gift Certificate Liability — ensure it exists for companies that never got one (e.g. tenants
// onboarded after the AccountingGapsPhase2 migration ran). Without it, GC GL postings silently no-op.
var has2500 = await _context.Set<Account>()
.IgnoreQueryFilters()
.AnyAsync(a => a.CompanyId == company.Id && a.AccountNumber == "2500" && !a.IsDeleted);
if (!has2500)
{
_context.Set<Account>().Add(new Account
{
AccountNumber = "2500",
Name = "Gift Certificate Liability",
AccountType = AccountType.Liability,
AccountSubType = AccountSubType.OtherCurrentLiability,
IsSystem = true,
IsActive = true,
Description = "Outstanding gift certificate obligations owed to certificate holders",
CompanyId = company.Id,
CreatedAt = now
});
await _context.SaveChangesAsync();
added++;
}
return added;
}
}
@@ -55,8 +55,7 @@ public class AccountsController : Controller
// GET: /Accounts
public async Task<IActionResult> Index()
{
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var accounts = await _unitOfWork.Accounts.FindAsync(a => a.CompanyId == companyId, false, a => a.ParentAccount);
var accounts = await _unitOfWork.Accounts.GetAllAsync(false, a => a.ParentAccount);
var dtos = _mapper.Map<List<AccountListDto>>(accounts.OrderBy(a => a.AccountNumber).ToList());
@@ -66,17 +65,6 @@ public class AccountsController : Controller
.OrderBy(g => (int)g.Key)
.ToList();
// Default-account pickers (Revenue / COGS / Inventory) — see SaveDefaultAccounts.
await PopulateDefaultAccountViewDataAsync(companyId, accounts);
// GL health: trial-balance net. Debit-normal (Asset/COGS/Expense) minus credit-normal
// (Liability/Equity/Revenue) should net to ~0 for balanced books. A non-zero value flags
// drift or one-sided postings (often opening balances entered without an offsetting entry).
ViewBag.TrialBalanceNet = accounts.Sum(a =>
(a.AccountType == AccountType.Asset || a.AccountType == AccountType.CostOfGoods
|| a.AccountType == AccountType.Expense)
? a.CurrentBalance : -a.CurrentBalance);
return View(grouped);
}
@@ -99,7 +87,18 @@ public class AccountsController : Controller
if (preSubType.HasValue)
{
dto.AccountSubType = preSubType.Value;
dto.AccountType = AccountClassification.TypeForSubType(preSubType.Value);
dto.AccountType = preSubType.Value switch
{
AccountSubType.Cash or AccountSubType.Checking or AccountSubType.Savings
or AccountSubType.AccountsReceivable or AccountSubType.Inventory or AccountSubType.FixedAsset
or AccountSubType.OtherCurrentAsset or AccountSubType.OtherAsset => AccountType.Asset,
AccountSubType.AccountsPayable or AccountSubType.CreditCard
or AccountSubType.OtherCurrentLiability or AccountSubType.LongTermLiability => AccountType.Liability,
AccountSubType.OwnersEquity or AccountSubType.RetainedEarnings => AccountType.Equity,
AccountSubType.Sales or AccountSubType.ServiceRevenue or AccountSubType.OtherIncome => AccountType.Revenue,
AccountSubType.CostOfGoodsSold => AccountType.CostOfGoods,
_ => AccountType.Expense
};
}
ViewBag.Inline = inline;
if (inline)
@@ -135,7 +134,7 @@ public class AccountsController : Controller
var currentUser = await _userManager.GetUserAsync(User);
// Check for duplicate account number
var existing = await _unitOfWork.Accounts.FindAsync(a => a.CompanyId == currentUser!.CompanyId && a.AccountNumber == dto.AccountNumber);
var existing = await _unitOfWork.Accounts.FindAsync(a => a.AccountNumber == dto.AccountNumber);
if (existing.Any())
{
ModelState.AddModelError(nameof(dto.AccountNumber), "An account with this number already exists.");
@@ -148,9 +147,6 @@ public class AccountsController : Controller
var account = _mapper.Map<Account>(dto);
account.CompanyId = currentUser!.CompanyId;
account.CreatedBy = currentUser.Email;
// Derive the parent type from the chosen sub-type so the two can never disagree —
// a mismatch would post with the wrong debit/credit sign (sign keys off sub-type).
account.AccountType = AccountClassification.TypeForSubType(account.AccountSubType);
await _unitOfWork.Accounts.AddAsync(account);
await _unitOfWork.CompleteAsync();
@@ -217,7 +213,7 @@ public class AccountsController : Controller
// Check duplicate number (excluding self)
var existing = await _unitOfWork.Accounts.FindAsync(
a => a.CompanyId == account.CompanyId && a.AccountNumber == dto.AccountNumber && a.Id != id);
a => a.AccountNumber == dto.AccountNumber && a.Id != id);
if (existing.Any())
{
ModelState.AddModelError(nameof(dto.AccountNumber), "An account with this number already exists.");
@@ -226,9 +222,6 @@ public class AccountsController : Controller
}
_mapper.Map(dto, account);
// Keep type consistent with the chosen sub-type (see Create) so the sign convention,
// which keys off sub-type, can never be at odds with the displayed account type.
account.AccountType = AccountClassification.TypeForSubType(account.AccountSubType);
account.UpdatedAt = DateTime.UtcNow;
account.UpdatedBy = (await _userManager.GetUserAsync(User))?.Email;
@@ -328,49 +321,20 @@ public class AccountsController : Controller
}
/// <summary>
/// Builds the Revenue / COGS / Inventory account dropdowns and the company's currently-selected
/// default account IDs for the "Default Accounts" card on the Chart of Accounts page. Revenue and
/// COGS are filtered by their top-level AccountType; the inventory-asset list shows all Asset
/// accounts (Inventory sub-type first) so a company that classified its inventory account
/// differently can still pick it. Reuses the already-loaded <paramref name="accounts"/> list.
/// One-time data repair for companies whose chart of accounts was imported from QuickBooks
/// IIF files. QuickBooks IIF exports store credit-normal account opening balances as negative
/// numbers (e.g. Revenue accounts), but the application's convention is to store all opening
/// balances as positive amounts with the credit/debit nature implied by account type. This
/// action flips negative opening balances on Revenue, Liability, and Equity accounts to their
/// absolute values. After running this, <see cref="RecalculateBalances"/> should be called to
/// propagate the corrected opening balances into <c>CurrentBalance</c>.
/// </summary>
private async Task PopulateDefaultAccountViewDataAsync(int companyId, IEnumerable<Account> accounts)
{
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
// POST: /Accounts/FixOpeningBalanceSigns
// 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.
[HttpPost, ValidateAntiForgeryToken]
[Authorize(Policy = AppConstants.Policies.CompanyAdminOnly)]
public async Task<IActionResult> SaveDefaultAccounts(
int? defaultRevenueAccountId, int? defaultCogsAccountId, int? defaultInventoryAccountId)
public async Task<IActionResult> FixOpeningBalanceSigns()
{
var companyId = _tenantContext.GetCurrentCompanyId();
if (companyId == null)
@@ -381,37 +345,30 @@ public class AccountsController : Controller
try
{
var prefs = await _unitOfWork.CompanyPreferences
.FirstOrDefaultAsync(p => p.CompanyId == companyId.Value && !p.IsDeleted);
if (prefs == null)
var accounts = await _unitOfWork.Accounts.FindAsync(a => a.CompanyId == companyId && a.IsActive);
int fixed_ = 0;
foreach (var acct in accounts)
{
TempData["Error"] = "Company preferences not found.";
return RedirectToAction(nameof(Index));
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);
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();
TempData["Success"] = "Default accounts saved. New items and invoice lines will use these when no account is chosen.";
TempData["Success"] = fixed_ > 0
? $"Fixed {fixed_} account(s) with negative opening balances. Run Recalculate Balances to update CurrentBalance."
: "No accounts needed fixing — all opening balances already have the correct sign.";
}
catch (Exception ex)
{
_logger.LogError(ex, "Error saving default accounts for company {CompanyId}", companyId);
TempData["Error"] = "An error occurred while saving the default accounts.";
_logger.LogError(ex, "Error fixing opening balance signs for company {CompanyId}", companyId);
TempData["Error"] = "An error occurred while fixing opening balances.";
}
return RedirectToAction(nameof(Index));
@@ -482,7 +439,7 @@ public class AccountsController : Controller
public async Task<IActionResult> YearEndClose()
{
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var history = (await _unitOfWork.YearEndCloses.FindAsync(y => y.CompanyId == companyId, false, y => y.JournalEntry))
var history = (await _unitOfWork.YearEndCloses.FindAsync(y => true, false, y => y.JournalEntry))
.OrderByDescending(y => y.ClosedYear)
.ToList();
@@ -507,7 +464,7 @@ public class AccountsController : Controller
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
// Idempotency check
var existing = (await _unitOfWork.YearEndCloses.FindAsync(y => y.CompanyId == companyId && y.ClosedYear == year)).FirstOrDefault();
var existing = (await _unitOfWork.YearEndCloses.FindAsync(y => y.ClosedYear == year)).FirstOrDefault();
if (existing != null)
{
TempData["Error"] = $"{year} has already been closed (JE {existing.JournalEntryId}).";
@@ -515,7 +472,7 @@ public class AccountsController : Controller
}
// Load all active accounts with balances
var accounts = (await _unitOfWork.Accounts.FindAsync(a => a.CompanyId == companyId && a.IsActive)).ToList();
var accounts = (await _unitOfWork.Accounts.FindAsync(a => a.IsActive)).ToList();
var revenueAccounts = accounts.Where(a => a.AccountType == AccountType.Revenue).ToList();
var expenseAccounts = accounts.Where(a =>
@@ -659,8 +616,7 @@ public class AccountsController : Controller
/// </summary>
private async Task PopulateDropdownsAsync(int? excludeId = null)
{
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var allAccounts = await _unitOfWork.Accounts.FindAsync(a => a.CompanyId == companyId && (excludeId == null || a.Id != excludeId.Value));
var allAccounts = await _unitOfWork.Accounts.FindAsync(a => excludeId == null || a.Id != excludeId.Value);
ViewBag.ParentAccounts = allAccounts
.OrderBy(a => a.AccountNumber)
@@ -65,7 +65,7 @@ public class AiQuickQuoteController : Controller
try
{
var powders = await _unitOfWork.InventoryItems.FindAsync(i =>
i.CompanyId == currentUser.CompanyId && i.Category != null && i.Category.ToLower().Contains("powder") && i.UnitCost > 0);
i.Category != null && i.Category.ToLower().Contains("powder") && i.UnitCost > 0);
if (powders.Any())
avgPowderCost = powders.Average(p => p.UnitCost);
}
@@ -180,7 +180,7 @@ public class AiQuickQuoteController : Controller
var context = new CompanyAiContext { ProfileText = costs.AiContextProfile };
var predictions = await _unitOfWork.AiItemPredictions.FindAsync(
p => p.CompanyId == companyId && !p.UserOverrodeEstimate && p.PredictedSurfaceAreaSqFt > 0 && p.PredictedUnitPrice > 0);
p => !p.UserOverrodeEstimate && p.PredictedSurfaceAreaSqFt > 0 && p.PredictedUnitPrice > 0);
context.AcceptedExamples = predictions
.OrderByDescending(p => p.CreatedAt)
@@ -213,9 +213,8 @@ public class AiQuickQuoteController : Controller
{
try
{
var companyId = (await _userManager.GetUserAsync(User))?.CompanyId ?? 0;
var inventory = await _unitOfWork.InventoryItems.FindAsync(
i => i.CompanyId == companyId && i.IsActive,
i => i.IsActive,
false,
i => i.InventoryCategory);
@@ -268,7 +267,7 @@ public class AiQuickQuoteController : Controller
private async Task<Customer> GetOrCreateWalkInCustomerAsync(int companyId)
{
var existing = (await _unitOfWork.Customers.FindAsync(
c => c.CompanyId == companyId && c.CompanyName == "Walk-In / Phone" && c.IsActive))
c => c.CompanyName == "Walk-In / Phone" && c.IsActive))
.FirstOrDefault();
if (existing != null) return existing;
@@ -8,7 +8,6 @@ using PowderCoating.Core.Entities;
using PowderCoating.Core.Enums;
using PowderCoating.Core.Interfaces;
using PowderCoating.Shared.Constants;
using PowderCoating.Web.Helpers;
namespace PowderCoating.Web.Controllers;
@@ -74,13 +73,6 @@ public class BankReconciliationsController : Controller
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
// The account being reconciled must be a real money account (Asset/Liability).
if (!await AccountGuard.IsValidMoneyAccountAsync(_unitOfWork, model.AccountId, companyId))
{
TempData["Error"] = "Select a valid bank, cash, or credit account to reconcile.";
return RedirectToAction(nameof(Create));
}
// Set beginning balance from last completed reconciliation for this account, or 0
var lastCompleted = (await _unitOfWork.BankReconciliations.FindAsync(
br => br.CompanyId == companyId
@@ -373,14 +365,11 @@ public class BankReconciliationsController : Controller
private async Task PopulateAccountDropdownAsync()
{
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
// Reconcilable accounts: any Asset (bank/cash) or Liability (credit card, line of
// credit) account. Filter by parent AccountType, not sub-type, so an account the
// company classified differently still shows up for reconciliation.
var accounts = await _unitOfWork.Accounts.FindAsync(
a => a.CompanyId == companyId && a.IsActive
&& (a.AccountType == AccountType.Asset
|| a.AccountType == AccountType.Liability));
a => a.IsActive
&& (a.AccountSubType == AccountSubType.Checking
|| a.AccountSubType == AccountSubType.Savings
|| a.AccountSubType == AccountSubType.Cash));
ViewBag.AccountSelectList = accounts
.OrderBy(a => a.AccountNumber)
@@ -202,14 +202,14 @@ public class BillsController : Controller
}
var apAccount = await _unitOfWork.Accounts.FirstOrDefaultAsync(
a => a.CompanyId == po.CompanyId && a.AccountSubType == AccountSubType.AccountsPayable);
a => a.AccountSubType == AccountSubType.AccountsPayable);
// Vendor default expense account, fall back to first expense/COGS account
int? defaultExpenseAccountId = po.Vendor?.DefaultExpenseAccountId;
if (!defaultExpenseAccountId.HasValue)
{
var fallbackAccount = await _unitOfWork.Accounts.FirstOrDefaultAsync(
a => a.CompanyId == po.CompanyId && a.IsActive && (a.AccountType == AccountType.Expense || a.AccountType == AccountType.CostOfGoods));
a => a.IsActive && (a.AccountType == AccountType.Expense || a.AccountType == AccountType.CostOfGoods));
defaultExpenseAccountId = fallbackAccount?.Id;
}
@@ -272,9 +272,8 @@ public class BillsController : Controller
};
// Pre-fill AP account
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var apAccount = await _unitOfWork.Accounts.FirstOrDefaultAsync(
a => a.CompanyId == companyId && a.AccountSubType == AccountSubType.AccountsPayable);
a => a.AccountSubType == AccountSubType.AccountsPayable);
dto.APAccountId = apAccount?.Id ?? 0;
// Pre-fill default expense account for vendor
@@ -340,16 +339,6 @@ public class BillsController : Controller
}
}
// Validate the pay-from account before entering the transaction so an invalid
// selection rejects the whole request rather than saving a bill with no payment.
if (payNow && paymentMethod.HasValue && bankAccountId.HasValue && currentUser != null
&& !await AccountGuard.IsValidMoneyAccountAsync(_unitOfWork, bankAccountId, currentUser.CompanyId))
{
ModelState.AddModelError(string.Empty, "Choose a valid bank or credit account to record the payment.");
await PopulateDropdownsAsync();
return View(dto);
}
Bill? bill = null;
// Bill entity, PO back-reference, and optional immediate payment all commit
@@ -462,12 +451,11 @@ public class BillsController : Controller
var dto = _mapper.Map<BillDto>(bill);
// Payment form defaults
// Payment sources: filter by parent AccountType (Asset or Liability), not sub-type,
// so accounts a company classified under a different sub-type still appear.
var bankAccounts = (await _unitOfWork.Accounts.FindAsync(
a => a.CompanyId == bill.CompanyId &&
(a.AccountType == AccountType.Asset ||
a.AccountType == AccountType.Liability)))
a => a.AccountSubType == AccountSubType.Cash ||
a.AccountSubType == AccountSubType.Checking ||
a.AccountSubType == AccountSubType.Savings ||
a.AccountSubType == AccountSubType.CreditCard))
.OrderBy(a => a.AccountNumber)
.ToList();
@@ -728,14 +716,6 @@ public class BillsController : Controller
return RedirectToAction(nameof(Details), new { id = dto.BillId });
}
// The pay-from account must be a real money account (Asset/Liability) — defense in depth
// against a tampered or stale selection before we post to it.
if (!await AccountGuard.IsValidMoneyAccountAsync(_unitOfWork, dto.BankAccountId, bill.CompanyId))
{
TempData["Error"] = "Select a valid bank or credit account to pay from.";
return RedirectToAction(nameof(Details), new { id = dto.BillId });
}
var currentUser = await _userManager.GetUserAsync(User);
var payment = _mapper.Map<BillPayment>(dto);
@@ -861,13 +841,6 @@ public class BillsController : Controller
var payment = await _unitOfWork.BillPayments.GetByIdAsync(dto.PaymentId);
if (payment == null) return NotFound();
// Reject an invalid new pay-from account before we move the balance to it.
if (!await AccountGuard.IsValidMoneyAccountAsync(_unitOfWork, dto.BankAccountId, payment.CompanyId))
{
TempData["Error"] = "Select a valid bank or credit account.";
return RedirectToAction(nameof(Details), new { id = dto.BillId });
}
var currentUser = await _userManager.GetUserAsync(User);
// If the bank account changed, reverse the old balance entry and apply the new one
@@ -1103,8 +1076,7 @@ public class BillsController : Controller
return Json(new { success = false, error = "File must be under 10 MB." });
// Load expense accounts for matching
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var allAccounts = await _unitOfWork.Accounts.FindAsync(a => a.CompanyId == companyId && a.IsActive);
var allAccounts = await _unitOfWork.Accounts.FindAsync(a => a.IsActive);
var expenseAccounts = allAccounts
.Where(a => a.AccountType == AccountType.Expense ||
a.AccountType == AccountType.CostOfGoods ||
@@ -1124,6 +1096,7 @@ public class BillsController : Controller
var imageBytes = ms.ToArray();
var result = await _accountingAi.ScanReceiptAsync(imageBytes, receiptImage.ContentType, expenseAccounts);
var companyId = int.TryParse(User.FindFirst("CompanyId")?.Value, out var cid) ? cid : 0;
var userId = User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value ?? "";
await _usageLogger.LogAsync(companyId, userId, AppConstants.AiFeatures.ReceiptScan, inputLength: (int)receiptImage.Length);
return Json(result);
@@ -1150,8 +1123,7 @@ public class BillsController : Controller
// Load expense accounts if not supplied
if (!request.AvailableAccounts.Any())
{
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var allAccounts = await _unitOfWork.Accounts.FindAsync(a => a.CompanyId == companyId && a.IsActive);
var allAccounts = await _unitOfWork.Accounts.FindAsync(a => a.IsActive);
request.AvailableAccounts = allAccounts
.Where(a => a.AccountType == AccountType.Expense ||
a.AccountType == AccountType.CostOfGoods ||
@@ -1198,7 +1170,7 @@ public class BillsController : Controller
var companyId = int.TryParse(User.FindFirst("CompanyId")?.Value, out var cid) ? cid : 0;
var cutoff = DateTime.Today.AddMonths(-12);
var bills = (await _unitOfWork.Bills.FindAsync(b => b.CompanyId == companyId, false, b => b.Vendor))
var bills = (await _unitOfWork.Bills.GetAllAsync(false, b => b.Vendor))
.Where(b => b.Status != BillStatus.Voided && b.BillDate >= cutoff)
.ToList();
@@ -30,8 +30,7 @@ public class BudgetsController : Controller
/// <summary>Lists all budgets for the current company ordered by fiscal year descending.</summary>
public async Task<IActionResult> Index()
{
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var budgets = (await _unitOfWork.Budgets.FindAsync(b => b.CompanyId == companyId, false, b => b.Lines))
var budgets = (await _unitOfWork.Budgets.FindAsync(b => true, false, b => b.Lines))
.OrderByDescending(b => b.FiscalYear)
.ThenBy(b => b.Name)
.ToList();
@@ -247,16 +246,15 @@ public class BudgetsController : Controller
private async Task<List<Account>> GetBudgetableAccountsAsync()
{
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var accounts = await _unitOfWork.Accounts.FindAsync(
a => a.CompanyId == companyId && a.IsActive && (a.AccountType == AccountType.Revenue || a.AccountType == AccountType.Expense));
a => a.IsActive && (a.AccountType == AccountType.Revenue || a.AccountType == AccountType.Expense));
return accounts.OrderBy(a => a.AccountNumber).ToList();
}
private async Task ClearDefaultFlagAsync(int companyId, int fiscalYear, int? excludeId)
{
var others = await _unitOfWork.Budgets.FindAsync(
b => b.CompanyId == companyId && b.IsDefault && b.FiscalYear == fiscalYear && b.Id != (excludeId ?? 0));
b => b.IsDefault && b.FiscalYear == fiscalYear && b.Id != (excludeId ?? 0));
foreach (var b in others)
{
b.IsDefault = false;
@@ -208,16 +208,10 @@ namespace PowderCoating.Web.Controllers
var useMetric = await _tenantContext.UseMetricSystemAsync();
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
{
CategoryId = categoryId ?? 0,
DisplayOrder = 0,
RevenueAccountId = prefs?.DefaultRevenueAccountId,
CogsAccountId = prefs?.DefaultCogsAccountId
DisplayOrder = 0
};
return View(model);
@@ -500,8 +494,7 @@ namespace PowderCoating.Web.Controllers
{
try
{
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var items = await _unitOfWork.CatalogItems.FindAsync(i => i.CompanyId == companyId && i.CategoryId == categoryId && i.IsActive);
var items = await _unitOfWork.CatalogItems.FindAsync(i => i.CategoryId == categoryId && i.IsActive);
var itemDtos = items
.OrderBy(i => i.DisplayOrder)
@@ -542,9 +535,8 @@ namespace PowderCoating.Web.Controllers
{
try
{
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var items = await _unitOfWork.CatalogItems.FindAsync(
i => i.CompanyId == companyId && i.IsMerchandise && i.IsActive, false, i => i.Category);
i => i.IsMerchandise && i.IsActive, false, i => i.Category);
var result = items
.OrderBy(i => i.Category.Name)
@@ -678,8 +670,7 @@ namespace PowderCoating.Web.Controllers
return;
}
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var accounts = await _unitOfWork.Accounts.FindAsync(a => a.CompanyId == companyId && a.IsActive);
var accounts = await _unitOfWork.Accounts.FindAsync(a => a.IsActive);
var revenueAccounts = accounts
.Where(a => a.AccountType == PowderCoating.Core.Enums.AccountType.Revenue)
@@ -695,13 +686,6 @@ namespace PowderCoating.Web.Controllers
ViewBag.RevenueAccounts = revenueAccounts;
ViewBag.CogsAccounts = cogsAccounts;
// Whether the company has configured default accounts — the views use this to label the
// blank dropdown option "(Default …)" vs "(None)".
var prefs = await _unitOfWork.CompanyPreferences
.FirstOrDefaultAsync(p => p.CompanyId == companyId && !p.IsDeleted);
ViewBag.HasDefaultRevenueAccount = prefs?.DefaultRevenueAccountId != null;
ViewBag.HasDefaultCogsAccount = prefs?.DefaultCogsAccountId != null;
}
/// <summary>
@@ -914,7 +898,7 @@ namespace PowderCoating.Web.Controllers
// Get all active catalog items with their categories
var items = await _unitOfWork.CatalogItems.FindAsync(
ci => ci.CompanyId == currentUser.CompanyId && ci.IsActive,
ci => ci.IsActive,
false,
ci => ci.Category
);
@@ -969,7 +953,7 @@ namespace PowderCoating.Web.Controllers
r => r.CompanyId == currentUser.CompanyId);
var report = existing.OrderByDescending(r => r.RunAt).FirstOrDefault();
var pricedItems = await _unitOfWork.CatalogItems.FindAsync(ci => ci.CompanyId == currentUser.CompanyId && ci.IsActive && ci.DefaultPrice > 0);
var pricedItems = await _unitOfWork.CatalogItems.FindAsync(ci => ci.IsActive && ci.DefaultPrice > 0);
ViewBag.ActiveItemCount = pricedItems.Count();
if (report != null)
@@ -1053,7 +1037,7 @@ namespace PowderCoating.Web.Controllers
// Load active catalog items with a real price — skip $0 items (placeholders,
// category headers, etc.) since there's no pricing to evaluate.
var items = (await _unitOfWork.CatalogItems.FindAsync(
ci => ci.CompanyId == currentUser.CompanyId && ci.IsActive && ci.DefaultPrice > 0, false, ci => ci.Category)).ToList();
ci => ci.IsActive && ci.DefaultPrice > 0, false, ci => ci.Category)).ToList();
if (items.Count == 0)
{
@@ -754,69 +754,6 @@ 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>
/// 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
@@ -990,7 +990,7 @@ public class CompanySettingsController : Controller
// Add job counts
foreach (var dto in dtos)
{
dto.JobCount = await _unitOfWork.Jobs.CountAsync(j => j.CompanyId == companyId && j.JobStatusId == dto.Id);
dto.JobCount = await _unitOfWork.Jobs.CountAsync(j => j.JobStatusId == dto.Id);
}
return Json(dtos);
@@ -1023,7 +1023,7 @@ public class CompanySettingsController : Controller
// Check if status code already exists for this company
var exists = await _unitOfWork.JobStatusLookups
.AnyAsync(s => s.CompanyId == companyId.Value && s.StatusCode == dto.StatusCode);
.AnyAsync(s => s.StatusCode == dto.StatusCode);
if (exists)
return Json(new { success = false, message = "Status code already exists" });
@@ -1100,7 +1100,7 @@ public class CompanySettingsController : Controller
return Json(new { success = false, message = "Cannot delete system-defined status" });
// Check if status is in use
var inUse = await _unitOfWork.Jobs.AnyAsync(j => j.CompanyId == status.CompanyId && j.JobStatusId == id);
var inUse = await _unitOfWork.Jobs.AnyAsync(j => j.JobStatusId == id);
if (inUse)
return Json(new { success = false, message = "Status is in use and cannot be deleted" });
@@ -1184,7 +1184,7 @@ public class CompanySettingsController : Controller
// Add job counts
foreach (var dto in dtos)
{
dto.JobCount = await _unitOfWork.Jobs.CountAsync(j => j.CompanyId == companyId && j.JobPriorityId == dto.Id);
dto.JobCount = await _unitOfWork.Jobs.CountAsync(j => j.JobPriorityId == dto.Id);
}
return Json(dtos);
@@ -1216,7 +1216,7 @@ public class CompanySettingsController : Controller
// Check if priority code already exists for this company
var exists = await _unitOfWork.JobPriorityLookups
.AnyAsync(p => p.CompanyId == companyId.Value && p.PriorityCode == dto.PriorityCode);
.AnyAsync(p => p.PriorityCode == dto.PriorityCode);
if (exists)
return Json(new { success = false, message = "Priority code already exists" });
@@ -1290,7 +1290,7 @@ public class CompanySettingsController : Controller
return Json(new { success = false, message = "Cannot delete system-defined priority" });
// Check if priority is in use
var inUse = await _unitOfWork.Jobs.AnyAsync(j => j.CompanyId == priority.CompanyId && j.JobPriorityId == id);
var inUse = await _unitOfWork.Jobs.AnyAsync(j => j.JobPriorityId == id);
if (inUse)
return Json(new { success = false, message = "Priority is in use and cannot be deleted" });
@@ -1370,7 +1370,7 @@ public class CompanySettingsController : Controller
// Add quote counts
foreach (var dto in dtos)
{
dto.QuoteCount = await _unitOfWork.Quotes.CountAsync(q => q.CompanyId == companyId && q.QuoteStatusId == dto.Id);
dto.QuoteCount = await _unitOfWork.Quotes.CountAsync(q => q.QuoteStatusId == dto.Id);
}
return Json(dtos);
@@ -1403,7 +1403,7 @@ public class CompanySettingsController : Controller
// Check if status code already exists for this company
var exists = await _unitOfWork.QuoteStatusLookups
.AnyAsync(s => s.CompanyId == companyId.Value && s.StatusCode == dto.StatusCode);
.AnyAsync(s => s.StatusCode == dto.StatusCode);
if (exists)
return Json(new { success = false, message = "Status code already exists" });
@@ -1411,7 +1411,7 @@ public class CompanySettingsController : Controller
if (dto.IsApprovedStatus)
{
var hasApproved = await _unitOfWork.QuoteStatusLookups
.AnyAsync(s => s.CompanyId == companyId.Value && s.IsApprovedStatus);
.AnyAsync(s => s.IsApprovedStatus);
if (hasApproved)
return Json(new { success = false, message = "Only one status can be marked as 'Approved'" });
}
@@ -1419,7 +1419,7 @@ public class CompanySettingsController : Controller
if (dto.IsConvertedStatus)
{
var hasConverted = await _unitOfWork.QuoteStatusLookups
.AnyAsync(s => s.CompanyId == companyId.Value && s.IsConvertedStatus);
.AnyAsync(s => s.IsConvertedStatus);
if (hasConverted)
return Json(new { success = false, message = "Only one status can be marked as 'Converted'" });
}
@@ -1466,7 +1466,7 @@ public class CompanySettingsController : Controller
if (dto.IsApprovedStatus && !status.IsApprovedStatus)
{
var hasApproved = await _unitOfWork.QuoteStatusLookups
.AnyAsync(s => s.CompanyId == status.CompanyId && s.Id != dto.Id && s.IsApprovedStatus);
.AnyAsync(s => s.Id != dto.Id && s.IsApprovedStatus);
if (hasApproved)
return Json(new { success = false, message = "Only one status can be marked as 'Approved'" });
}
@@ -1474,7 +1474,7 @@ public class CompanySettingsController : Controller
if (dto.IsConvertedStatus && !status.IsConvertedStatus)
{
var hasConverted = await _unitOfWork.QuoteStatusLookups
.AnyAsync(s => s.CompanyId == status.CompanyId && s.Id != dto.Id && s.IsConvertedStatus);
.AnyAsync(s => s.Id != dto.Id && s.IsConvertedStatus);
if (hasConverted)
return Json(new { success = false, message = "Only one status can be marked as 'Converted'" });
}
@@ -1512,7 +1512,7 @@ public class CompanySettingsController : Controller
return Json(new { success = false, message = "Cannot delete system-defined status" });
// Check if status is in use
var inUse = await _unitOfWork.Quotes.AnyAsync(q => q.CompanyId == status.CompanyId && q.QuoteStatusId == id);
var inUse = await _unitOfWork.Quotes.AnyAsync(q => q.QuoteStatusId == id);
if (inUse)
return Json(new { success = false, message = "Status is in use and cannot be deleted" });
@@ -1909,7 +1909,7 @@ public class CompanySettingsController : Controller
// Add appointment counts
foreach (var dto in dtos)
{
dto.AppointmentCount = await _unitOfWork.Appointments.CountAsync(a => a.CompanyId == companyId && a.AppointmentTypeId == dto.Id);
dto.AppointmentCount = await _unitOfWork.Appointments.CountAsync(a => a.AppointmentTypeId == dto.Id);
}
return Json(dtos);
@@ -1941,7 +1941,7 @@ public class CompanySettingsController : Controller
// Check if type code already exists for this company
var exists = await _unitOfWork.AppointmentTypeLookups
.AnyAsync(t => t.CompanyId == companyId.Value && t.TypeCode == dto.TypeCode);
.AnyAsync(t => t.TypeCode == dto.TypeCode);
if (exists)
return Json(new { success = false, message = "Type code already exists" });
@@ -2015,7 +2015,7 @@ public class CompanySettingsController : Controller
return Json(new { success = false, message = "Cannot delete system-defined type" });
// Check if type is in use
var inUse = await _unitOfWork.Appointments.AnyAsync(a => a.CompanyId == type.CompanyId && a.AppointmentTypeId == id);
var inUse = await _unitOfWork.Appointments.AnyAsync(a => a.AppointmentTypeId == id);
if (inUse)
return Json(new { success = false, message = "Type is in use and cannot be deleted" });
@@ -2095,7 +2095,7 @@ public class CompanySettingsController : Controller
// Add item counts
foreach (var dto in dtos)
{
dto.ItemCount = await _unitOfWork.InventoryItems.CountAsync(i => i.CompanyId == companyId && i.InventoryCategoryId == dto.Id);
dto.ItemCount = await _unitOfWork.InventoryItems.CountAsync(i => i.InventoryCategoryId == dto.Id);
}
return Json(dtos);
@@ -2127,7 +2127,7 @@ public class CompanySettingsController : Controller
// Check if category code already exists for this company
var exists = await _unitOfWork.InventoryCategoryLookups
.AnyAsync(c => c.CompanyId == companyId.Value && c.CategoryCode == dto.CategoryCode);
.AnyAsync(c => c.CategoryCode == dto.CategoryCode);
if (exists)
return Json(new { success = false, message = "Category code already exists" });
@@ -2193,7 +2193,7 @@ public class CompanySettingsController : Controller
return Json(new { success = false, message = "Category not found" });
// Check if category is in use
var inUse = await _unitOfWork.InventoryItems.AnyAsync(i => i.CompanyId == category.CompanyId && i.InventoryCategoryId == id);
var inUse = await _unitOfWork.InventoryItems.AnyAsync(i => i.InventoryCategoryId == id);
if (inUse)
return Json(new { success = false, message = "Category is in use and cannot be deleted" });
@@ -2404,7 +2404,7 @@ public class CompanySettingsController : Controller
return Json(new { success = false, message = "Oven not found." });
// Check if any quotes reference this oven
var usageCount = await _unitOfWork.Quotes.CountAsync(q => q.CompanyId == companyId.Value && q.OvenCostId == id);
var usageCount = await _unitOfWork.Quotes.CountAsync(q => q.OvenCostId == id);
if (usageCount > 0)
return Json(new { success = false, message = $"Cannot delete: {usageCount} quote(s) reference this oven. Deactivate it instead." });
@@ -47,9 +47,8 @@ public class CreditMemosController : Controller
[HttpGet]
public async Task<IActionResult> Index(string? status, string? search)
{
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var memos = await _unitOfWork.CreditMemos.FindAsync(
m => m.CompanyId == companyId, false,
m => true, false,
m => m.Customer);
if (!string.IsNullOrWhiteSpace(search))
@@ -178,13 +177,6 @@ public class CreditMemosController : Controller
await _unitOfWork.CompleteAsync();
// GL: the credit is a liability owed to the customer — DR Sales Discounts (contra-revenue)
// / CR Customer Credits (2350). Relieved when the memo is applied to an invoice.
var discountAcct = await _unitOfWork.Accounts.FirstOrDefaultAsync(a => a.CompanyId == customer.CompanyId && a.AccountNumber == "4950" && a.IsActive);
var customerCreditsAcct = await _unitOfWork.Accounts.FirstOrDefaultAsync(a => a.CompanyId == customer.CompanyId && a.AccountNumber == "2350" && a.IsActive);
await _accountBalanceService.DebitAsync(discountAcct?.Id, vm.Amount);
await _accountBalanceService.CreditAsync(customerCreditsAcct?.Id, vm.Amount);
TempData["Success"] = $"Credit memo {memoNumber} for {vm.Amount:C} issued to {DisplayName(customer)}.";
return RedirectToAction(nameof(Details), new { id = memo.Id });
}
@@ -260,14 +252,18 @@ public class CreditMemosController : Controller
await _unitOfWork.Invoices.UpdateAsync(invoice);
}
// GL: applying the credit relieves the liability — DR Customer Credits (2350) / CR AR.
// The contra-revenue (Sales Discounts) was recognized when the credit was issued.
// Keeps Account.CurrentBalance in sync for RecalculateAllAsync and direct readers.
// GL: DR 4950 Sales Discounts (contra-revenue) / CR AR.
// The dynamic report computation attributes credit memo applications to both
// accounts already; this call keeps Account.CurrentBalance in sync for
// RecalculateAllAsync and any tools that read it directly.
var arAcct = await _unitOfWork.Accounts.FirstOrDefaultAsync(
a => a.CompanyId == invoice.CompanyId && a.AccountSubType == AccountSubType.AccountsReceivable && a.IsActive);
var customerCreditsAcct = await _unitOfWork.Accounts.FirstOrDefaultAsync(
a => a.CompanyId == invoice.CompanyId && a.AccountNumber == "2350" && a.IsActive);
await _accountBalanceService.DebitAsync(customerCreditsAcct?.Id, applyAmount);
a => a.AccountSubType == AccountSubType.AccountsReceivable && a.IsActive);
var discountAcct = await _unitOfWork.Accounts.FirstOrDefaultAsync(
a => a.AccountNumber == "4950" && a.IsActive)
?? await _unitOfWork.Accounts.FirstOrDefaultAsync(
a => a.AccountType == AccountType.Revenue && a.IsActive
&& a.Name.ToLower().Contains("discount"));
await _accountBalanceService.DebitAsync(discountAcct?.Id, applyAmount);
await _accountBalanceService.CreditAsync(arAcct?.Id, applyAmount);
await _unitOfWork.CompleteAsync();
@@ -312,15 +308,6 @@ public class CreditMemosController : Controller
await _unitOfWork.Customers.UpdateAsync(memo.Customer);
}
// GL: reverse the unapplied issuance — DR Customer Credits (2350) / CR Sales Discounts.
if (remaining > 0)
{
var ccAcct = await _unitOfWork.Accounts.FirstOrDefaultAsync(a => a.CompanyId == memo.CompanyId && a.AccountNumber == "2350" && a.IsActive);
var sdAcct = await _unitOfWork.Accounts.FirstOrDefaultAsync(a => a.CompanyId == memo.CompanyId && a.AccountNumber == "4950" && a.IsActive);
await _accountBalanceService.DebitAsync(ccAcct?.Id, remaining);
await _accountBalanceService.CreditAsync(sdAcct?.Id, remaining);
}
await _unitOfWork.CompleteAsync();
TempData["Success"] = "Credit memo voided. Unapplied balance reversed from customer credit.";
return RedirectToAction(nameof(Details), new { id });
@@ -1411,8 +1411,7 @@ public class CustomersController : Controller
/// </summary>
private async Task PopulatePricingTiersAsync()
{
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var tiers = await _unitOfWork.PricingTiers.FindAsync(t => t.CompanyId == companyId && t.IsActive);
var tiers = await _unitOfWork.PricingTiers.FindAsync(t => t.IsActive);
ViewBag.PricingTiers = tiers
.OrderBy(t => t.TierName)
.Select(t => new SelectListItem
@@ -499,7 +499,7 @@ public class DashboardController : Controller
return null;
// These share the same scoped DbContext so must run sequentially
var hasStatusHistory = await _unitOfWork.JobStatusHistory.AnyAsync(h => h.CompanyId == companyId);
var hasStatusHistory = await _unitOfWork.JobStatusHistory.AnyAsync(_ => true);
// ignoreQueryFilters so soft-deleted lookups (UpdatedAt set on delete) are also visible
var hasCustomizedLookups = await _unitOfWork.JobStatusLookups.AnyAsync(
j => j.CompanyId == companyId && j.UpdatedAt != null,
@@ -9,7 +9,6 @@ using PowderCoating.Core.Entities;
using PowderCoating.Core.Enums;
using PowderCoating.Core.Interfaces;
using PowderCoating.Shared.Constants;
using PowderCoating.Web.Helpers;
using QuestPDF.Fluent;
using QuestPDF.Helpers;
using QuestPDF.Infrastructure;
@@ -64,8 +63,7 @@ public class DepositsController : Controller
string paymentMethod,
DateTime receivedDate,
string? reference,
string? notes,
int? depositAccountId = null)
string? notes)
{
try
{
@@ -82,32 +80,7 @@ public class DepositsController : Controller
if (currentUser == null) return Unauthorized();
var receiptNumber = await GenerateReceiptNumberAsync(currentUser.CompanyId);
// Resolve the bank/asset account the deposit lands in. The user now picks this on
// the form; if they didn't (or the value is stale), fall back to the legacy
// auto-pick of the first Checking/Cash account. Validate any user-supplied id
// belongs to this company (defense in depth — the global filter alone isn't enough).
int? depositAcctId = null;
if (depositAccountId.HasValue &&
await AccountGuard.IsValidMoneyAccountAsync(_unitOfWork, depositAccountId, currentUser.CompanyId))
{
depositAcctId = depositAccountId;
}
depositAcctId ??= await GetCheckingAccountIdAsync(currentUser.CompanyId);
// Guard against an unbalanced GL posting: this deposit credits the Customer Deposits
// liability (2300). If that account exists but we have no bank/asset account to debit,
// the entry would be one-sided. Block it so the user picks a deposit account first.
// (When 2300 doesn't exist — e.g. a company not using accounting — no GL posts at all,
// so a missing bank account is harmless and the deposit is allowed through.)
var custDepositsAcctId = await GetCustomerDepositsAccountIdAsync(currentUser.CompanyId);
if (custDepositsAcctId != null && depositAcctId == null)
return Json(new
{
success = false,
message = "Select a deposit account (the bank/asset account this payment lands in) " +
"before recording. None is configured for your company yet."
});
var checkingAcctId = await GetCheckingAccountIdAsync(currentUser.CompanyId);
var deposit = new Deposit
{
@@ -120,7 +93,7 @@ public class DepositsController : Controller
ReceivedDate = receivedDate,
Reference = reference,
Notes = notes,
DepositAccountId = depositAcctId,
DepositAccountId = checkingAcctId,
RecordedById = currentUser.Id,
CompanyId = currentUser.CompanyId,
CreatedAt = DateTime.UtcNow,
@@ -131,7 +104,8 @@ public class DepositsController : Controller
await _unitOfWork.CompleteAsync();
// GL: DR Checking (cash received) / CR Customer Deposits 2300 (liability until applied to invoice).
await _accountBalanceService.DebitAsync(depositAcctId, deposit.Amount);
var custDepositsAcctId = await GetCustomerDepositsAccountIdAsync(currentUser.CompanyId);
await _accountBalanceService.DebitAsync(checkingAcctId, deposit.Amount);
await _accountBalanceService.CreditAsync(custDepositsAcctId, deposit.Amount);
return Json(new
@@ -105,9 +105,8 @@ public class ExpensesController : Controller
ViewBag.To = to?.ToString("yyyy-MM-dd");
ViewBag.TotalAmount = dtos.Sum(e => e.Amount);
var legacyUser = await _userManager.GetUserAsync(User);
var expenseAccounts = (await _unitOfWork.Accounts.FindAsync(
a => a.CompanyId == legacyUser!.CompanyId && a.IsActive &&
a => a.IsActive &&
(a.AccountType == AccountType.Expense || a.AccountType == AccountType.CostOfGoods)))
.OrderBy(a => a.AccountNumber)
.ToList();
@@ -480,8 +479,7 @@ public class ExpensesController : Controller
if (!request.AvailableAccounts.Any())
{
var currentUser = await _userManager.GetUserAsync(User);
var allAccounts = await _unitOfWork.Accounts.FindAsync(a => a.CompanyId == currentUser!.CompanyId && a.IsActive);
var allAccounts = await _unitOfWork.Accounts.FindAsync(a => a.IsActive);
request.AvailableAccounts = allAccounts
.Where(a => a.AccountType == AccountType.Expense ||
a.AccountType == AccountType.CostOfGoods)
@@ -44,9 +44,8 @@ public class FixedAssetsController : Controller
[HttpGet]
public async Task<IActionResult> Index()
{
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var assets = await _unitOfWork.FixedAssets.FindAsync(
fa => fa.CompanyId == companyId, false,
fa => true, false,
fa => fa.AssetAccount,
fa => fa.DepreciationExpenseAccount,
fa => fa.AccumDepreciationAccount);
@@ -193,7 +192,7 @@ public class FixedAssetsController : Controller
var currentUser = await _userManager.GetUserAsync(User);
var assets = await _unitOfWork.FixedAssets.FindAsync(
fa => fa.CompanyId == companyId && !fa.IsDisposed, false,
fa => !fa.IsDisposed, false,
fa => fa.DepreciationEntries);
int posted = 0, skipped = 0;
@@ -314,8 +313,7 @@ public class FixedAssetsController : Controller
private async Task PopulateAccountsAsync()
{
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var accounts = await _unitOfWork.Accounts.FindAsync(a => a.CompanyId == companyId && a.IsActive);
var accounts = await _unitOfWork.Accounts.FindAsync(a => a.IsActive);
var list = accounts.OrderBy(a => a.AccountNumber).ThenBy(a => a.Name).ToList();
ViewBag.AssetAccounts = list
@@ -62,9 +62,8 @@ public class GiftCertificatesController : Controller
/// </summary>
public async Task<IActionResult> Index(string? searchTerm, string? statusFilter)
{
var companyId = (await _userManager.GetUserAsync(User))?.CompanyId ?? 0;
var certs = await _unitOfWork.GiftCertificates.FindAsync(
gc => gc.CompanyId == companyId, false,
gc => true, false,
gc => gc.RecipientCustomer,
gc => gc.PurchasingCustomer);
@@ -255,14 +254,14 @@ public class GiftCertificatesController : Controller
if (dto.IssuedReason == GiftCertificateIssuedReason.Sold)
{
var checkingAcctId = await _unitOfWork.Accounts.FirstOrDefaultAsync(
a => a.CompanyId == companyId && a.IsActive && (a.AccountSubType == AccountSubTypeEnum.Checking
a => a.IsActive && (a.AccountSubType == AccountSubTypeEnum.Checking
|| a.AccountSubType == AccountSubTypeEnum.Cash));
await _accountBalanceService.DebitAsync(checkingAcctId?.Id, cert.OriginalAmount);
}
else
{
var discountAcctId = await _unitOfWork.Accounts.FirstOrDefaultAsync(
a => a.CompanyId == companyId && a.IsActive && a.AccountNumber == "4950");
a => a.IsActive && a.AccountNumber == "4950");
await _accountBalanceService.DebitAsync(discountAcctId?.Id, cert.OriginalAmount);
}
@@ -311,7 +310,7 @@ public class GiftCertificatesController : Controller
var companyId = currentUser?.CompanyId ?? 0;
var gcLiabilityAcctId = await GetGcLiabilityAccountIdAsync(companyId);
var otherIncomeAcctId = await _unitOfWork.Accounts.FirstOrDefaultAsync(
a => a.CompanyId == companyId && a.IsActive && a.AccountSubType == AccountSubTypeEnum.OtherIncome);
a => a.IsActive && a.AccountSubType == AccountSubTypeEnum.OtherIncome);
await _accountBalanceService.DebitAsync(gcLiabilityAcctId, remaining);
await _accountBalanceService.CreditAsync(otherIncomeAcctId?.Id, remaining);
}
@@ -421,8 +420,7 @@ public class GiftCertificatesController : Controller
/// </summary>
private async Task PopulateCustomersAsync()
{
var companyId = (await _userManager.GetUserAsync(User))?.CompanyId ?? 0;
var customers = await _unitOfWork.Customers.FindAsync(c => c.CompanyId == companyId && c.IsActive);
var customers = await _unitOfWork.Customers.FindAsync(c => c.IsActive);
var list = customers
.OrderBy(c => c.CompanyName ?? c.ContactFirstName)
.Select(c => new SelectListItem
@@ -439,7 +437,7 @@ public class GiftCertificatesController : Controller
private async Task<int?> GetGcLiabilityAccountIdAsync(int companyId)
{
var acct = await _unitOfWork.Accounts.FirstOrDefaultAsync(
a => a.CompanyId == companyId && a.IsActive && a.AccountNumber == "2500");
a => a.IsActive && a.AccountNumber == "2500");
return acct?.Id;
}
@@ -479,14 +477,14 @@ public class GiftCertificatesController : Controller
if (dto.IssuedReason == GiftCertificateIssuedReason.Sold)
{
var acct = await _unitOfWork.Accounts.FirstOrDefaultAsync(
a => a.CompanyId == companyId && a.IsActive && (a.AccountSubType == AccountSubTypeEnum.Checking
a => a.IsActive && (a.AccountSubType == AccountSubTypeEnum.Checking
|| a.AccountSubType == AccountSubTypeEnum.Cash));
checkingAcctId = acct?.Id;
}
else
{
var acct = await _unitOfWork.Accounts.FirstOrDefaultAsync(
a => a.CompanyId == companyId && a.IsActive && a.AccountNumber == "4950");
a => a.IsActive && a.AccountNumber == "4950");
discountAcctId = acct?.Id;
}
@@ -126,12 +126,11 @@ public class InAppNotificationsController : Controller
public async Task<IActionResult> MarkAllRead()
{
var now = DateTime.UtcNow;
var companyId = _tenant.GetCurrentCompanyId() ?? 0;
var unread = _tenant.IsPlatformAdmin()
? (await _unitOfWork.InAppNotifications.FindAsync(
n => !n.IsDeleted && n.CompanyId == 0 && !n.IsRead, ignoreQueryFilters: true)).ToList()
: (await _unitOfWork.InAppNotifications.FindAsync(n => n.CompanyId == companyId && !n.IsRead)).ToList();
: (await _unitOfWork.InAppNotifications.FindAsync(n => !n.IsRead)).ToList();
foreach (var n in unread)
{
@@ -193,9 +193,8 @@ public class InventoryController : Controller
return RedirectToAction(nameof(Index));
var loc = location.Trim();
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var items = await _unitOfWork.InventoryItems.FindAsync(
i => i.CompanyId == companyId && i.Location != null && i.Location.ToLower() == loc.ToLower());
i => i.Location != null && i.Location.ToLower() == loc.ToLower());
var dtos = _mapper.Map<List<InventoryListDto>>(items.OrderBy(i => i.Name).ToList());
ViewBag.Location = loc;
@@ -276,18 +275,10 @@ public class InventoryController : Controller
ViewBag.UseMetric = 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
{
CoverageSqFtPerLb = 30,
TransferEfficiency = 65,
InventoryAccountId = prefs?.DefaultInventoryAccountId,
CogsAccountId = prefs?.DefaultCogsAccountId
TransferEfficiency = 65
});
}
@@ -307,27 +298,6 @@ public class InventoryController : Controller
return View(dto);
}
var category = dto.InventoryCategoryId.HasValue
? await _unitOfWork.InventoryCategoryLookups.GetByIdAsync(dto.InventoryCategoryId.Value)
: null;
var duplicate = await FindInventoryDuplicateAsync(
dto.SKU,
dto.Manufacturer,
dto.ManufacturerPartNumber,
dto.ColorName,
category?.IsCoating == true);
if (duplicate != null &&
(duplicate.MatchType == InventoryDuplicateMatchType.Sku ||
dto.DuplicateOverrideInventoryItemId != duplicate.Item.Id))
{
ModelState.AddModelError(
duplicate.MatchType == InventoryDuplicateMatchType.Sku ? nameof(dto.SKU) : string.Empty,
BuildDuplicateMessage(duplicate));
await PopulateDropdowns();
return View(dto);
}
try
{
var item = _mapper.Map<InventoryItem>(dto);
@@ -336,8 +306,12 @@ public class InventoryController : Controller
item.Name = ToTitleCase(item.Name);
// Populate legacy Category field from lookup table
if (category != null)
item.Category = category.DisplayName;
if (item.InventoryCategoryId.HasValue)
{
var category = await _unitOfWork.InventoryCategoryLookups.GetByIdAsync(item.InventoryCategoryId.Value);
if (category != null)
item.Category = category.DisplayName;
}
// Link to the platform catalog row when this item's identity matches one, so the detail
// screen can show manufacturer-level status (discontinued / cannot reorder) and quotes
@@ -1068,12 +1042,45 @@ public class InventoryController : Controller
// TDS cure fallback — same logic as AiLookup button
await ApplyTdsCureFallbackAsync(aiResult, colorName);
var duplicate = await FindInventoryDuplicateAsync(
null,
manufacturer,
sku,
colorName,
isCoating: true);
// Check if this product already exists in the tenant's inventory.
// Match by ManufacturerPartNumber first (most precise); fall back to color name + manufacturer.
// Returns the first active match so the UI can prompt to add stock inline.
int? existingInventoryId = null;
string? existingInventoryName = null;
decimal? existingQuantityOnHand = null;
string? existingUnitOfMeasure = null;
InventoryItem? existingHit = null;
if (!string.IsNullOrEmpty(sku))
{
var skuLower = sku.ToLower();
var byPart = await _unitOfWork.InventoryItems.FindAsync(i =>
i.ManufacturerPartNumber != null &&
i.ManufacturerPartNumber.ToLower() == skuLower);
existingHit = byPart.FirstOrDefault();
}
if (existingHit == null && !string.IsNullOrEmpty(colorName))
{
var nameLower = colorName.ToLower();
var mfrLower = manufacturer?.ToLower() ?? "";
var byName = await _unitOfWork.InventoryItems.FindAsync(i =>
(i.ColorName != null && i.ColorName.ToLower() == nameLower) ||
i.Name.ToLower() == nameLower);
existingHit = byName.FirstOrDefault(i =>
string.IsNullOrEmpty(mfrLower) ||
(i.Manufacturer ?? "").ToLower().Contains(mfrLower) ||
mfrLower.Contains((i.Manufacturer ?? "").ToLower().Trim()));
}
if (existingHit != null)
{
existingInventoryId = existingHit.Id;
existingInventoryName = existingHit.Name;
existingQuantityOnHand = existingHit.QuantityOnHand;
existingUnitOfMeasure = existingHit.UnitOfMeasure;
}
return Json(new
{
@@ -1098,61 +1105,16 @@ public class InventoryController : Controller
vendorName = manufacturer,
wasInCatalog = wasInCatalog,
addedToCatalog = addedToCatalog,
existingInventoryId = duplicate?.Item.Id,
existingInventoryName = duplicate?.Item.Name,
existingQuantityOnHand = duplicate?.Item.QuantityOnHand,
existingUnitOfMeasure = duplicate?.Item.UnitOfMeasure,
duplicateMatchType = duplicate?.MatchType.ToString(),
existingInventoryId = existingInventoryId,
existingInventoryName = existingInventoryName,
existingQuantityOnHand = existingQuantityOnHand,
existingUnitOfMeasure = existingUnitOfMeasure,
reasoning = aiResult.Reasoning,
});
}
/// <summary>
/// Checks the current tenant's active inventory for an existing SKU or powder identity.
/// Uses the same matcher as label scanning and repeats the tenant boundary explicitly.
/// </summary>
[HttpGet]
public async Task<IActionResult> CheckDuplicate(
string? sku,
int? categoryId,
string? manufacturer,
string? manufacturerPartNumber,
string? colorName,
int? currentId = null)
{
var category = categoryId.HasValue
? await _unitOfWork.InventoryCategoryLookups.GetByIdAsync(categoryId.Value)
: null;
var duplicate = await FindInventoryDuplicateAsync(
sku,
manufacturer,
manufacturerPartNumber,
colorName,
category?.IsCoating == true,
currentId);
if (duplicate == null)
return Json(new { hasDuplicate = false });
return Json(new
{
hasDuplicate = true,
isBlocking = duplicate.MatchType == InventoryDuplicateMatchType.Sku,
matchType = duplicate.MatchType.ToString(),
message = BuildDuplicateMessage(duplicate),
existingInventoryId = duplicate.Item.Id,
existingInventoryName = duplicate.Item.Name,
existingSku = duplicate.Item.SKU,
existingManufacturer = duplicate.Item.Manufacturer,
existingColorName = duplicate.Item.ColorName,
existingQuantityOnHand = duplicate.Item.QuantityOnHand,
existingUnitOfMeasure = duplicate.Item.UnitOfMeasure,
});
}
/// <summary>
/// Adds stock to an existing inventory item from the shared duplicate prompt.
/// Adds stock to an existing inventory item from the label scanner inline prompt.
/// Creates a Purchase transaction and updates QuantityOnHand without navigating away.
/// </summary>
[HttpPost]
@@ -1398,48 +1360,6 @@ public class InventoryController : Controller
}
}
private async Task<InventoryDuplicateMatch?> FindInventoryDuplicateAsync(
string? sku,
string? manufacturer,
string? manufacturerPartNumber,
string? colorName,
bool isCoating,
int? excludeId = null)
{
var companyId = _tenantContext.GetCurrentCompanyId();
if (!companyId.HasValue || companyId.Value <= 0)
return null;
// Explicit CompanyId predicate is intentional defense-in-depth on top of the global filter.
var tenantInventory = await _unitOfWork.InventoryItems.FindAsync(
i => i.CompanyId == companyId.Value,
false,
i => i.InventoryCategory!);
return InventoryDuplicateMatcher.Find(
tenantInventory,
companyId.Value,
sku,
manufacturer,
manufacturerPartNumber,
colorName,
isCoating,
excludeId);
}
private static string BuildDuplicateMessage(InventoryDuplicateMatch duplicate)
{
return duplicate.MatchType switch
{
InventoryDuplicateMatchType.Sku =>
$"SKU '{duplicate.Item.SKU}' is already used by '{duplicate.Item.Name}'.",
InventoryDuplicateMatchType.ManufacturerPartNumber =>
$"This manufacturer's part number is already recorded as '{duplicate.Item.Name}' ({duplicate.Item.SKU}).",
_ =>
$"{duplicate.Item.Manufacturer} {duplicate.Item.ColorName ?? duplicate.Item.Name} is already in inventory as '{duplicate.Item.Name}' ({duplicate.Item.SKU})."
};
}
private static decimal GetEffectiveTransferEfficiency(decimal? transferEfficiency)
{
return transferEfficiency ?? DefaultTransferEfficiency;
@@ -1532,9 +1452,8 @@ public class InventoryController : Controller
{
try
{
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var allCoatings = (await _unitOfWork.InventoryItems.FindAsync(
i => i.CompanyId == companyId && i.InventoryCategory != null && i.InventoryCategory.IsCoating,
i => i.InventoryCategory != null && i.InventoryCategory.IsCoating,
false,
i => i.InventoryCategory))
.OrderBy(i => i.Manufacturer).ThenBy(i => i.ColorName).ThenBy(i => i.Name)
@@ -1611,7 +1530,7 @@ public class InventoryController : Controller
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
ViewBag.AiInventoryAssistEnabled = await _subscriptionService.IsAiInventoryAssistEnabledAsync(companyId);
var vendors = (await _unitOfWork.Vendors.FindAsync(v => v.CompanyId == companyId && v.IsActive, false, v => v.Categories))
var vendors = (await _unitOfWork.Vendors.FindAsync(v => v.IsActive, false, v => v.Categories))
.OrderBy(v => v.CompanyName).ToList();
ViewBag.Vendors = new SelectList(vendors, "Id", "CompanyName");
@@ -1650,17 +1569,12 @@ public class InventoryController : Controller
new SelectListItem { Value = "rolls", Text = "Rolls" }
};
var accounts = await _unitOfWork.Accounts.FindAsync(a => a.CompanyId == companyId && a.IsActive);
var accounts = await _unitOfWork.Accounts.FindAsync(a => a.IsActive);
// Show ALL asset accounts, not just the Inventory sub-type. Companies that created
// their inventory account manually often land on a different asset sub-type (e.g.
// Other Current Asset), which previously left this dropdown empty. Listing every
// asset account lets them pick whatever they actually use; Inventory sub-type
// accounts are surfaced first as the recommended choice.
ViewBag.InventoryAccounts = accounts
.Where(a => a.AccountType == AccountType.Asset)
.OrderByDescending(a => a.AccountSubType == AccountSubType.Inventory)
.ThenBy(a => a.AccountNumber)
.Where(a => a.AccountType == AccountType.Asset
&& a.AccountSubType == AccountSubType.Inventory)
.OrderBy(a => a.AccountNumber)
.Select(a => new SelectListItem($"{a.AccountNumber} {a.Name}", a.Id.ToString()))
.ToList();
@@ -1669,13 +1583,6 @@ public class InventoryController : Controller
.OrderBy(a => a.AccountNumber)
.Select(a => new SelectListItem($"{a.AccountNumber} {a.Name}", a.Id.ToString()))
.ToList();
// Whether the company has configured default accounts — the views use this to label the
// blank dropdown option "(Default …)" vs "(None)".
var prefs = await _unitOfWork.CompanyPreferences
.FirstOrDefaultAsync(p => p.CompanyId == companyId && !p.IsDeleted);
ViewBag.HasDefaultInventoryAccount = prefs?.DefaultInventoryAccountId != null;
ViewBag.HasDefaultCogsAccount = prefs?.DefaultCogsAccountId != null;
}
/// <summary>
@@ -1853,16 +1760,13 @@ public class InventoryController : Controller
item.UpdatedAt = DateTime.UtcNow;
await _unitOfWork.InventoryItems.UpdateAsync(item);
// Record at the effective (weighted-average) unit cost so TotalCost equals the COGS actually
// posted — the GL recompute reads TotalCost to reproduce the DR COGS / CR Inventory entry.
var effectiveUnitCost = item.AverageCost > 0 ? item.AverageCost : item.UnitCost;
var txn = new InventoryTransaction
{
InventoryItemId = item.Id,
TransactionType = transactionType,
Quantity = -quantityUsed,
UnitCost = effectiveUnitCost,
TotalCost = quantityUsed * effectiveUnitCost,
UnitCost = item.UnitCost,
TotalCost = quantityUsed * item.UnitCost,
TransactionDate = DateTime.UtcNow,
BalanceAfter = item.QuantityOnHand,
JobId = jobId,
@@ -1876,7 +1780,7 @@ public class InventoryController : Controller
if (item.CogsAccountId.HasValue && item.InventoryAccountId.HasValue)
{
var cost = txn.TotalCost;
var cost = quantityUsed * (item.AverageCost > 0 ? item.AverageCost : item.UnitCost);
await _accountBalanceService.DebitAsync(item.CogsAccountId, cost);
await _accountBalanceService.CreditAsync(item.InventoryAccountId, cost);
}
@@ -2227,7 +2131,7 @@ public class InventoryController : Controller
return BadRequest("Only usage transactions can be edited here.");
var allJobs = await _unitOfWork.Jobs.FindAsync(
j => j.CompanyId == txn.CompanyId && !j.JobStatus.IsTerminalStatus,
j => !j.JobStatus.IsTerminalStatus,
false,
j => j.Customer,
j => j.JobStatus);
@@ -7,7 +7,6 @@ using PowderCoating.Application.DTOs.Common;
using PowderCoating.Application.DTOs.Invoice;
using PowderCoating.Application.DTOs.Quote;
using PowderCoating.Application.Interfaces;
using PowderCoating.Core.Accounting;
using PowderCoating.Core.Entities;
using Microsoft.AspNetCore.Mvc.Rendering;
using PowderCoating.Core.Enums;
@@ -241,12 +240,11 @@ public class InvoicesController : Controller
ViewBag.SortDirection = gridRequest.SortDirection;
// Pill badge counts — always global (not scoped to current filter/page)
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
ViewBag.UnpaidCount = await _unitOfWork.Invoices.CountAsync(i => i.CompanyId == companyId &&
(i.Status == InvoiceStatus.Draft || i.Status == InvoiceStatus.Sent || i.Status == InvoiceStatus.Overdue));
ViewBag.PartialCount = await _unitOfWork.Invoices.CountAsync(i => i.CompanyId == companyId && i.Status == InvoiceStatus.PartiallyPaid);
ViewBag.PaidCount = await _unitOfWork.Invoices.CountAsync(i => i.CompanyId == companyId && i.Status == InvoiceStatus.Paid);
ViewBag.AllCount = await _unitOfWork.Invoices.CountAsync(i => i.CompanyId == companyId);
ViewBag.UnpaidCount = await _unitOfWork.Invoices.CountAsync(i =>
i.Status == InvoiceStatus.Draft || i.Status == InvoiceStatus.Sent || i.Status == InvoiceStatus.Overdue);
ViewBag.PartialCount = await _unitOfWork.Invoices.CountAsync(i => i.Status == InvoiceStatus.PartiallyPaid);
ViewBag.PaidCount = await _unitOfWork.Invoices.CountAsync(i => i.Status == InvoiceStatus.Paid);
ViewBag.AllCount = await _unitOfWork.Invoices.CountAsync();
return View(pagedResult);
}
@@ -306,9 +304,8 @@ public class InvoicesController : Controller
&& company?.StripeConnectStatus == StripeConnectStatus.Active;
// Expense accounts for the write-off bad-debt modal
var expenseCompanyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var expenseAccounts = await _unitOfWork.Accounts.FindAsync(
a => a.CompanyId == expenseCompanyId && a.IsActive && a.AccountType == AccountType.Expense);
a => a.IsActive && a.AccountType == AccountType.Expense);
ViewBag.ExpenseAccounts = expenseAccounts
.OrderBy(a => a.AccountNumber).ThenBy(a => a.Name)
.Select(a => new SelectListItem($"{a.AccountNumber} {a.Name}", a.Id.ToString()))
@@ -413,15 +410,8 @@ public class InvoicesController : Controller
.ToDictionary(ci => ci.Id)
: new Dictionary<int, CatalogItem>();
// Fall back to the company's configured default revenue account when a catalog item
// has no specific account; if none is configured (or it has since been deactivated),
// fall back to the seeded 4000 account. The IsActive check mirrors the 4000 lookup so a
// deactivated default doesn't keep being posted to.
Account? defaultRevenueAccount = null;
if (prefs?.DefaultRevenueAccountId != null)
defaultRevenueAccount = await _unitOfWork.Accounts.FirstOrDefaultAsync(
a => a.Id == prefs.DefaultRevenueAccountId.Value && a.IsActive);
defaultRevenueAccount ??= await _unitOfWork.Accounts
// Fall back to the default revenue account (4000) if a catalog item has no specific account
var defaultRevenueAccount = await _unitOfWork.Accounts
.FirstOrDefaultAsync(a => a.AccountNumber == "4000" && a.IsActive);
// Deserialize the job's pricing snapshot up front — it is authoritative for discount,
@@ -2414,7 +2404,7 @@ public class InvoicesController : Controller
return Json(new { taxPercent = 0m, taxRateName = (string?)null });
var defaultRate = await _unitOfWork.TaxRates
.FirstOrDefaultAsync(r => r.CompanyId == customer.CompanyId && r.IsDefault && r.IsActive && !r.IsDeleted);
.FirstOrDefaultAsync(r => r.IsDefault && r.IsActive && !r.IsDeleted);
return Json(new
{
@@ -2451,7 +2441,7 @@ public class InvoicesController : Controller
// Merchandise items for the invoice merch picker (all active IsMerchandise items)
var allMerchItems = await _unitOfWork.CatalogItems.FindAsync(
i => i.CompanyId == companyId && i.IsMerchandise && i.IsActive, false, i => i.Category);
i => i.IsMerchandise && i.IsActive, false, i => i.Category);
var merchItems = allMerchItems
.OrderBy(i => i.Category.Name).ThenBy(i => i.DisplayOrder).ThenBy(i => i.Name)
.Select(i => new { i.Id, i.Name, i.SKU, CategoryName = i.Category.Name, i.DefaultPrice, i.RevenueAccountId })
@@ -2467,8 +2457,7 @@ public class InvoicesController : Controller
/// </summary>
private async Task PopulateBankAccountsAsync()
{
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var accounts = await _unitOfWork.Accounts.FindAsync(a => a.CompanyId == companyId && a.IsActive
var accounts = await _unitOfWork.Accounts.FindAsync(a => a.IsActive
&& (a.AccountSubType == AccountSubType.Cash ||
a.AccountSubType == AccountSubType.Checking ||
a.AccountSubType == AccountSubType.Savings));
@@ -2483,7 +2472,7 @@ public class InvoicesController : Controller
private async Task<int?> GetCheckingAccountIdAsync(int companyId)
{
var acct = await _unitOfWork.Accounts.FirstOrDefaultAsync(
a => a.CompanyId == companyId && a.IsActive && (a.AccountSubType == AccountSubType.Checking
a => a.IsActive && (a.AccountSubType == AccountSubType.Checking
|| a.AccountSubType == AccountSubType.Cash));
return acct?.Id;
}
@@ -2492,23 +2481,7 @@ public class InvoicesController : Controller
private async Task<int?> GetCustomerDepositsAccountIdAsync(int companyId)
{
var acct = await _unitOfWork.Accounts.FirstOrDefaultAsync(
a => a.CompanyId == companyId && a.IsActive && a.AccountNumber == "2300");
return acct?.Id;
}
/// <summary>Returns the Sales Returns &amp; Allowances contra-revenue account (4960) for refunds.</summary>
private async Task<int?> GetSalesReturnsAccountIdAsync(int companyId)
{
var acct = await _unitOfWork.Accounts.FirstOrDefaultAsync(
a => a.CompanyId == companyId && a.IsActive && a.AccountNumber == "4960");
return acct?.Id;
}
/// <summary>Returns the Customer Credits liability account (2350) for store credit / credit memos.</summary>
private async Task<int?> GetCustomerCreditsAccountIdAsync(int companyId)
{
var acct = await _unitOfWork.Accounts.FirstOrDefaultAsync(
a => a.CompanyId == companyId && a.IsActive && a.AccountNumber == "2350");
a => a.IsActive && a.AccountNumber == "2300");
return acct?.Id;
}
@@ -2516,7 +2489,7 @@ public class InvoicesController : Controller
private async Task<int?> GetArAccountIdAsync(int companyId)
{
var accounts = await _unitOfWork.Accounts.FindAsync(
a => a.CompanyId == companyId && a.IsActive && a.AccountSubType == AccountSubType.AccountsReceivable);
a => a.IsActive && a.AccountSubType == AccountSubType.AccountsReceivable);
return accounts.FirstOrDefault()?.Id;
}
@@ -2527,7 +2500,7 @@ public class InvoicesController : Controller
private async Task<int?> GetBadDebtAccountIdAsync(int companyId)
{
var expenses = await _unitOfWork.Accounts.FindAsync(
a => a.CompanyId == companyId && a.IsActive && a.AccountType == AccountType.Expense);
a => a.IsActive && a.AccountType == AccountType.Expense);
return expenses.FirstOrDefault(a => a.Name.Contains("bad", StringComparison.OrdinalIgnoreCase)
|| a.Name.Contains("debt", StringComparison.OrdinalIgnoreCase))?.Id
?? expenses.FirstOrDefault()?.Id;
@@ -2558,9 +2531,9 @@ public class InvoicesController : Controller
private async Task<int?> ResolveSalesTaxAccountIdAsync(int companyId)
{
var taxAccount = await _unitOfWork.Accounts.FirstOrDefaultAsync(
a => a.CompanyId == companyId && a.AccountNumber == "2200" && a.IsActive);
a => a.AccountNumber == "2200" && a.IsActive);
taxAccount ??= await _unitOfWork.Accounts.FirstOrDefaultAsync(
a => a.CompanyId == companyId && a.AccountType == AccountType.Liability && a.IsActive && a.Name.ToLower().Contains("tax"));
a => a.AccountType == AccountType.Liability && a.IsActive && a.Name.ToLower().Contains("tax"));
return taxAccount?.Id;
}
@@ -2572,9 +2545,9 @@ public class InvoicesController : Controller
private async Task<int?> GetSalesDiscountAccountIdAsync(int companyId)
{
var discountAccount = await _unitOfWork.Accounts.FirstOrDefaultAsync(
a => a.CompanyId == companyId && a.AccountNumber == "4950" && a.IsActive);
a => a.AccountNumber == "4950" && a.IsActive);
discountAccount ??= await _unitOfWork.Accounts.FirstOrDefaultAsync(
a => a.CompanyId == companyId && a.AccountType == AccountType.Revenue && a.IsActive && a.Name.ToLower().Contains("discount"));
a => a.AccountType == AccountType.Revenue && a.IsActive && a.Name.ToLower().Contains("discount"));
return discountAccount?.Id;
}
@@ -2582,7 +2555,7 @@ public class InvoicesController : Controller
private async Task<int?> GetGcLiabilityAccountIdAsync(int companyId)
{
var acct = await _unitOfWork.Accounts.FirstOrDefaultAsync(
a => a.CompanyId == companyId && a.IsActive && a.AccountNumber == "2500");
a => a.IsActive && a.AccountNumber == "2500");
return acct?.Id;
}
@@ -2690,32 +2663,24 @@ public class InvoicesController : Controller
}
await _unitOfWork.CompleteAsync();
// GL: store credit is a liability owed to the customer — DR Sales Discounts (contra-revenue)
// / CR Customer Credits (2350). The liability is relieved when the credit memo is applied.
var scDiscountAcctId = await GetSalesDiscountAccountIdAsync(companyId);
var scCustomerCreditsAcctId = await GetCustomerCreditsAccountIdAsync(companyId);
await _accountBalanceService.DebitAsync(scDiscountAcctId, dto.Amount);
await _accountBalanceService.CreditAsync(scCustomerCreditsAcctId, dto.Amount);
TempData["Success"] = $"Refund of {dto.Amount:C} applied as store credit. Credit memo {memoNumber} created.";
}
else
{
// "Reverse the sale": a cash refund contra's the original sale instead of re-opening AR.
// GL: DR Sales Returns (revenue portion) + DR Sales Tax Payable (tax portion) / CR Bank.
// Customer AR balance is intentionally left unchanged — the invoice stays paid and the
// sale is reversed via the contra accounts. The split is centralised in RefundAllocation
// so LedgerService and FinancialReportService recompute the same way.
// Adjust customer AR balance — they're owed money back
if (invoice.Customer != null)
{
invoice.Customer.CurrentBalance -= dto.Amount;
await _unitOfWork.Customers.UpdateAsync(invoice.Customer);
}
await _unitOfWork.CompleteAsync();
var (returnsPortion, taxPortion) = RefundAllocation.Split(dto.Amount, invoice.TaxAmount, invoice.Total);
var salesReturnsAccountId = await GetSalesReturnsAccountIdAsync(companyId);
var salesTaxAccountId = invoice.SalesTaxAccountId ?? await ResolveSalesTaxAccountIdAsync(companyId);
await _accountBalanceService.DebitAsync(salesReturnsAccountId, returnsPortion);
if (taxPortion > 0)
await _accountBalanceService.DebitAsync(salesTaxAccountId, taxPortion);
// GL: DR AR (un-collects the payment) / CR Bank (cash leaves).
// Mirrors how FinancialReportService accounts for refunds:
// arTotalCredits -= refundTotal; refundsByAcct credits the bank account.
var arAccountId = await GetArAccountIdAsync(companyId);
await _accountBalanceService.DebitAsync(arAccountId, dto.Amount);
await _accountBalanceService.CreditAsync(dto.DepositAccountId, dto.Amount);
TempData["Success"] = $"Refund of {dto.Amount:C} recorded successfully. Please issue the refund manually.";
@@ -2766,14 +2731,12 @@ public class InvoicesController : Controller
if (refund.RefundMethod == PaymentMethod.StoreCredit)
{
// Cancel the linked CreditMemo and reverse the unapplied store-credit remainder.
decimal creditReversed = refund.Amount;
// Cancel the linked CreditMemo and reverse the CreditBalance
if (refund.CreditMemoId.HasValue)
{
var memo = await _unitOfWork.CreditMemos.GetByIdAsync(refund.CreditMemoId.Value);
if (memo != null && memo.Status == CreditMemoStatus.Active)
{
creditReversed = memo.Amount - memo.AmountApplied; // only the unapplied remainder
memo.Status = CreditMemoStatus.Voided;
memo.UpdatedAt = DateTime.UtcNow;
await _unitOfWork.CreditMemos.UpdateAsync(memo);
@@ -2782,30 +2745,22 @@ public class InvoicesController : Controller
if (customer != null)
{
customer.CreditBalance = Math.Max(0, customer.CreditBalance - creditReversed);
customer.CreditBalance -= refund.Amount;
await _unitOfWork.Customers.UpdateAsync(customer);
}
// GL: reverse the unapplied store-credit issuance — DR Customer Credits / CR Sales Discounts.
if (creditReversed > 0)
{
var ccAcctId = await GetCustomerCreditsAccountIdAsync(refund.Invoice.CompanyId);
var sdAcctId = await GetSalesDiscountAccountIdAsync(refund.Invoice.CompanyId);
await _accountBalanceService.DebitAsync(ccAcctId, creditReversed);
await _accountBalanceService.CreditAsync(sdAcctId, creditReversed);
}
}
else
{
// Reverse the "reverse the sale" posting: CR Sales Returns + CR Sales Tax Payable / DR Bank.
// The customer's AR balance was not touched when the refund was issued, so it is not touched here.
var (returnsPortion, taxPortion) = RefundAllocation.Split(refund.Amount, refund.Invoice.TaxAmount, refund.Invoice.Total);
var salesReturnsAccountId = await GetSalesReturnsAccountIdAsync(refund.Invoice.CompanyId);
var salesTaxAccountId = refund.Invoice.SalesTaxAccountId ?? await ResolveSalesTaxAccountIdAsync(refund.Invoice.CompanyId);
// Reverse the AR balance adjustment
if (customer != null)
{
customer.CurrentBalance += refund.Amount;
await _unitOfWork.Customers.UpdateAsync(customer);
}
await _accountBalanceService.CreditAsync(salesReturnsAccountId, returnsPortion);
if (taxPortion > 0)
await _accountBalanceService.CreditAsync(salesTaxAccountId, taxPortion);
// GL reversal: CR AR / DR Bank — mirrors the DR AR / CR Bank posted in IssueRefund.
var arAccountId = await GetArAccountIdAsync(refund.Invoice.CompanyId);
await _accountBalanceService.CreditAsync(arAccountId, refund.Amount);
await _accountBalanceService.DebitAsync(refund.DepositAccountId, refund.Amount);
}
@@ -2868,14 +2823,6 @@ public class InvoicesController : Controller
}
await _unitOfWork.CompleteAsync();
// GL: the credit is a liability owed to the customer — DR Sales Discounts (contra-revenue)
// / CR Customer Credits (2350). Relieved when the memo is applied to an invoice.
var cmDiscountAcctId = await GetSalesDiscountAccountIdAsync(companyId);
var cmCustomerCreditsAcctId = await GetCustomerCreditsAccountIdAsync(companyId);
await _accountBalanceService.DebitAsync(cmDiscountAcctId, dto.Amount);
await _accountBalanceService.CreditAsync(cmCustomerCreditsAcctId, dto.Amount);
TempData["Success"] = $"Credit memo {memoNumber} for {dto.Amount:C} issued to customer.";
}
catch (Exception ex)
@@ -2962,11 +2909,9 @@ public class InvoicesController : Controller
}
// GL: DR Sales Discounts 4950 / CR AR — same as CreditMemosController.Apply.
// GL: applying the credit relieves the liability — DR Customer Credits (2350) / CR AR.
// (The contra-revenue was already recognized as Sales Discounts when the credit was issued.)
var arAccountId = await GetArAccountIdAsync(invoice.CompanyId);
var customerCreditsAcctId = await GetCustomerCreditsAccountIdAsync(invoice.CompanyId);
await _accountBalanceService.DebitAsync(customerCreditsAcctId, applyAmount);
var discountAcctId = await GetSalesDiscountAccountIdAsync(invoice.CompanyId);
await _accountBalanceService.DebitAsync(discountAcctId, applyAmount);
await _accountBalanceService.CreditAsync(arAccountId, applyAmount);
await _unitOfWork.CompleteAsync();
@@ -3009,15 +2954,6 @@ public class InvoicesController : Controller
await _unitOfWork.Customers.UpdateAsync(memo.Customer);
}
// GL: reverse the unapplied issuance — DR Customer Credits (2350) / CR Sales Discounts.
if (remaining > 0)
{
var ccAcctId = await GetCustomerCreditsAccountIdAsync(memo.CompanyId);
var sdAcctId = await GetSalesDiscountAccountIdAsync(memo.CompanyId);
await _accountBalanceService.DebitAsync(ccAcctId, remaining);
await _accountBalanceService.CreditAsync(sdAcctId, remaining);
}
await _unitOfWork.CompleteAsync();
TempData["Success"] = "Credit memo voided.";
return RedirectToAction(nameof(Details), new { id = invoiceId });
@@ -213,29 +213,24 @@ public class JobsController : Controller
// Pill badge counts — always global (not scoped to current filter/page)
var today = DateTime.Today;
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
ViewBag.AllJobCount = await _unitOfWork.Jobs.CountAsync(j => j.CompanyId == companyId);
ViewBag.AllJobCount = await _unitOfWork.Jobs.CountAsync();
ViewBag.ActiveCount = await _unitOfWork.Jobs.CountAsync(j =>
j.CompanyId == companyId
&& j.JobStatus.StatusCode != AppConstants.StatusCodes.Job.Completed
j.JobStatus.StatusCode != AppConstants.StatusCodes.Job.Completed
&& j.JobStatus.StatusCode != AppConstants.StatusCodes.Job.ReadyForPickup
&& j.JobStatus.StatusCode != AppConstants.StatusCodes.Job.Delivered
&& j.JobStatus.StatusCode != AppConstants.StatusCodes.Job.Cancelled);
ViewBag.OverdueCount = await _unitOfWork.Jobs.CountAsync(j =>
j.CompanyId == companyId
&& j.DueDate < today
j.DueDate < today
&& j.JobStatus.StatusCode != AppConstants.StatusCodes.Job.Completed
&& j.JobStatus.StatusCode != AppConstants.StatusCodes.Job.ReadyForPickup
&& j.JobStatus.StatusCode != AppConstants.StatusCodes.Job.Delivered
&& j.JobStatus.StatusCode != AppConstants.StatusCodes.Job.Cancelled);
ViewBag.CompletedCount = await _unitOfWork.Jobs.CountAsync(j =>
j.CompanyId == companyId &&
(j.JobStatus.StatusCode == AppConstants.StatusCodes.Job.Completed
j.JobStatus.StatusCode == AppConstants.StatusCodes.Job.Completed
|| j.JobStatus.StatusCode == AppConstants.StatusCodes.Job.ReadyForPickup
|| j.JobStatus.StatusCode == AppConstants.StatusCodes.Job.Delivered));
|| j.JobStatus.StatusCode == AppConstants.StatusCodes.Job.Delivered);
ViewBag.ReadyCount = await _unitOfWork.Jobs.CountAsync(j =>
j.CompanyId == companyId
&& j.JobStatus.StatusCode == AppConstants.StatusCodes.Job.ReadyForPickup);
j.JobStatus.StatusCode == AppConstants.StatusCodes.Job.ReadyForPickup);
// Set ViewBag for sorting
ViewBag.SearchTerm = searchTerm;
@@ -451,9 +446,6 @@ public class JobsController : Controller
ViewBag.JobInvoiceStatus = jobInvoice?.Status;
ViewBag.JobVoidedInvoices = voidedInvoices;
// Bank/asset accounts the deposit can land in (deposit modal dropdown)
ViewBag.DepositAccounts = await AccountingDropdownHelper.LoadDepositAccountsAsync(_unitOfWork, companyId);
// Workers dropdown for inline assignment
await PopulateWorkersDropdown();
@@ -2176,12 +2168,10 @@ public class JobsController : Controller
try
{
var today = date?.Date ?? DateTime.Today;
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
// Load all non-terminal statuses for the progress strip (excluding nav/hold/cancel)
var allStatusesEnum = await _unitOfWork.JobStatusLookups.FindAsync(s =>
s.CompanyId == companyId
&& s.StatusCode != AppConstants.StatusCodes.Job.OnHold && s.StatusCode != AppConstants.StatusCodes.Job.Cancelled
s.StatusCode != AppConstants.StatusCodes.Job.OnHold && s.StatusCode != AppConstants.StatusCodes.Job.Cancelled
&& s.StatusCode != AppConstants.StatusCodes.Job.Delivered && s.StatusCode != AppConstants.StatusCodes.Job.Quoted
&& s.StatusCode != AppConstants.StatusCodes.Job.Pending && s.StatusCode != AppConstants.StatusCodes.Job.Approved);
var allStatuses = allStatusesEnum.OrderBy(s => s.DisplayOrder).ToList();
@@ -2191,7 +2181,7 @@ public class JobsController : Controller
// Get existing priority records for today
var existingPriorities = await _unitOfWork.JobDailyPriorities
.FindAsync(p => p.CompanyId == companyId && p.ScheduledDate.Date == today);
.FindAsync(p => p.ScheduledDate.Date == today);
var priorityDict = existingPriorities.ToDictionary(p => p.JobId);
@@ -2288,8 +2278,7 @@ public class JobsController : Controller
if (!companyId.HasValue) return RedirectToAction(nameof(Index));
var allStatusesEnum = await _unitOfWork.JobStatusLookups.FindAsync(s =>
s.CompanyId == companyId.Value
&& !s.IsTerminalStatus
!s.IsTerminalStatus
&& s.StatusCode != AppConstants.StatusCodes.Job.OnHold && s.StatusCode != AppConstants.StatusCodes.Job.Cancelled
&& s.StatusCode != AppConstants.StatusCodes.Job.Delivered);
var allStatuses = allStatusesEnum.OrderBy(s => s.DisplayOrder).ToList();
@@ -3008,17 +2997,13 @@ public class JobsController : Controller
inventoryItem.QuantityOnHand -= deductNow;
await _unitOfWork.InventoryItems.UpdateAsync(inventoryItem);
// Record the consumption at the effective (weighted-average) unit cost so the
// transaction's TotalCost equals the COGS actually posted — the GL recompute
// reads TotalCost to reproduce the DR COGS / CR Inventory entry.
var effectiveUnitCost = inventoryItem.AverageCost > 0 ? inventoryItem.AverageCost : inventoryItem.UnitCost;
var transaction = new InventoryTransaction
{
InventoryItemId = inventoryItem.Id,
TransactionType = InventoryTransactionType.JobUsage,
Quantity = -deductNow,
UnitCost = effectiveUnitCost,
TotalCost = effectiveUnitCost * deductNow,
UnitCost = inventoryItem.UnitCost,
TotalCost = inventoryItem.UnitCost * deductNow,
TransactionDate = DateTime.UtcNow,
JobId = job.Id,
Reference = job.JobNumber,
@@ -3030,7 +3015,7 @@ public class JobsController : Controller
if (inventoryItem.CogsAccountId.HasValue && inventoryItem.InventoryAccountId.HasValue)
{
var cost = transaction.TotalCost;
var cost = deductNow * (inventoryItem.AverageCost > 0 ? inventoryItem.AverageCost : inventoryItem.UnitCost);
await _accountBalanceService.DebitAsync(inventoryItem.CogsAccountId, cost);
await _accountBalanceService.CreditAsync(inventoryItem.InventoryAccountId, cost);
}
@@ -57,14 +57,13 @@ public class JobsPriorityController : Controller
public async Task<IActionResult> Index(DateTime? date)
{
var today = date?.Date ?? DateTime.Today;
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
// Get all jobs scheduled for today with related data
var jobs = await _unitOfWork.Jobs.GetScheduledJobsForDateAsync(today);
// Get existing priority records for today
var existingPriorities = await _unitOfWork.JobDailyPriorities
.FindAsync(p => p.CompanyId == companyId && p.ScheduledDate.Date == today);
.FindAsync(p => p.ScheduledDate.Date == today);
var priorityDict = existingPriorities.ToDictionary(p => p.JobId);
@@ -91,6 +90,7 @@ public class JobsPriorityController : Controller
.ToList();
// Get priorities and workers for modal options
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var priorities = await _unitOfWork.JobPriorityLookups.FindAsync(p => p.CompanyId == companyId);
var workers = await _userManager.Users
.Where(u => u.CompanyId == companyId && u.IsActive && u.CompanyRole != null)
@@ -99,7 +99,7 @@ public class JobsPriorityController : Controller
// Get maintenance records scheduled for today (Scheduled or InProgress)
var maintenanceItems = (await _unitOfWork.MaintenanceRecords.FindAsync(
m => m.CompanyId == companyId && m.ScheduledDate.Date == today &&
m => m.ScheduledDate.Date == today &&
(m.Status == MaintenanceStatus.Scheduled || m.Status == MaintenanceStatus.InProgress),
false,
m => m.Equipment, m => m.AssignedUser))
@@ -169,11 +169,10 @@ public class JobsPriorityController : Controller
}
var today = DateTime.Today;
var cid = _tenantContext.GetCurrentCompanyId() ?? 0;
// Get all existing priority records for today
var existingPriorities = await _unitOfWork.JobDailyPriorities
.FindAsync(p => p.CompanyId == cid && p.ScheduledDate.Date == today);
.FindAsync(p => p.ScheduledDate.Date == today);
var priorityDict = existingPriorities.ToDictionary(p => p.JobId);
@@ -118,7 +118,7 @@ public class JournalEntriesController : Controller
// Load account names for lines
var accountIds = je.Lines.Select(l => l.AccountId).Distinct().ToList();
var accounts = await _unitOfWork.Accounts.FindAsync(a => a.CompanyId == je.CompanyId && accountIds.Contains(a.Id));
var accounts = await _unitOfWork.Accounts.FindAsync(a => accountIds.Contains(a.Id));
ViewBag.AccountMap = accounts.ToDictionary(a => a.Id, a => $"{a.AccountNumber} {a.Name}");
// Reversal metadata
@@ -196,113 +196,6 @@ 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));
}
// Don't let a remittance exceed the outstanding liability — overpaying would push Sales Tax
// Payable into an abnormal (debit) balance. The 0.005 tolerance absorbs decimal rounding.
if (amount > taxAcct.CurrentBalance + 0.005m)
{
TempData["Error"] = $"Payment of {amount:C} exceeds the Sales Tax Payable balance of {taxAcct.CurrentBalance:C}. Enter an amount up to the outstanding liability.";
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]
@@ -474,8 +367,7 @@ public class JournalEntriesController : Controller
private async Task PopulateAccountDropdownAsync()
{
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var accounts = await _unitOfWork.Accounts.FindAsync(a => a.CompanyId == companyId && a.IsActive);
var accounts = await _unitOfWork.Accounts.FindAsync(a => a.IsActive);
ViewBag.AccountSelectList = accounts
.OrderBy(a => a.AccountNumber)
.Select(a => new SelectListItem
@@ -582,9 +582,7 @@ public class KioskController : Controller
[Authorize]
public async Task<IActionResult> Intakes(string? filter)
{
var companyId = GetCurrentCompanyId();
var sessions = await _unitOfWork.KioskSessions.FindAsync(
s => s.CompanyId == companyId, false,
var sessions = await _unitOfWork.KioskSessions.GetAllAsync(false,
s => s.LinkedCustomer,
s => s.LinkedJob);
@@ -11,9 +11,7 @@ using PowderCoating.Web.Hubs;
namespace PowderCoating.Web.Controllers;
// Oven batch scheduling is shop-floor job management — gated to CanManageJobs so
// low-privilege roles can't create/modify/delete batches. (Audit #3, 2026-06-20.)
[Authorize(Policy = AppConstants.Policies.CanManageJobs)]
[Authorize]
public class OvenSchedulerController : Controller
{
private readonly IUnitOfWork _unitOfWork;
@@ -63,17 +61,16 @@ public class OvenSchedulerController : Controller
public async Task<IActionResult> Index(DateTime? date, string goal = "maximize_throughput")
{
var scheduledDate = date?.Date ?? DateTime.Today;
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
// Load active Named Ovens — filter IsActive at database level
var ovenCosts = (await _unitOfWork.OvenCosts.FindAsync(o => o.CompanyId == companyId && o.IsActive))
var ovenCosts = (await _unitOfWork.OvenCosts.FindAsync(o => o.IsActive))
.OrderBy(o => o.DisplayOrder).ThenBy(o => o.Label)
.ToList();
// Load batches for the selected date — filter at database level with includes
var scheduledDateEnd = scheduledDate.AddDays(1);
var batches = (await _unitOfWork.OvenBatches.FindAsync(
b => b.CompanyId == companyId && b.ScheduledDate >= scheduledDate && b.ScheduledDate < scheduledDateEnd
b => b.ScheduledDate >= scheduledDate && b.ScheduledDate < scheduledDateEnd
&& b.Status != OvenBatchStatus.Cancelled,
false,
b => b.OvenCost, b => b.Items))
@@ -101,7 +98,7 @@ public class OvenSchedulerController : Controller
// Load jobs in the queue — filter by status at database level
var queueJobs = (await _unitOfWork.Jobs.FindAsync(
j => j.CompanyId == companyId && j.JobStatus != null && QueueableStatuses.Contains(j.JobStatus.StatusCode),
j => j.JobStatus != null && QueueableStatuses.Contains(j.JobStatus.StatusCode),
false,
j => j.Customer, j => j.JobStatus, j => j.JobPriority, j => j.JobItems))
.ToList();
@@ -129,14 +126,14 @@ public class OvenSchedulerController : Controller
// Determine which coats are already scheduled — filter out removed/cancelled at database level
var scheduledCoatIds = (await _unitOfWork.OvenBatchItems.FindAsync(
i => i.CompanyId == companyId && i.Status != OvenBatchItemStatus.Removed && i.Batch.Status != OvenBatchStatus.Cancelled,
i => i.Status != OvenBatchItemStatus.Removed && i.Batch.Status != OvenBatchStatus.Cancelled,
false,
i => i.Batch))
.Select(i => i.JobItemCoatId)
.ToHashSet();
// Get company defaults
var companyCosts = await _unitOfWork.CompanyOperatingCosts.FirstOrDefaultAsync(c => c.CompanyId == companyId);
var companyCosts = await _unitOfWork.CompanyOperatingCosts.FirstOrDefaultAsync(c => true);
var defaultCycleMinutes = companyCosts?.DefaultOvenCycleMinutes ?? 45;
// Build the view model
@@ -25,6 +25,7 @@ public class PowderCatalogController : Controller
private readonly IColumbiaCatalogSyncService _columbiaSyncService;
private readonly IPowderCatalogUpsertService _upsertService;
private readonly IPlatformSettingsService _platformSettings;
private readonly IConfiguration _config;
private readonly ILogger<PowderCatalogController> _logger;
public PowderCatalogController(
@@ -33,6 +34,7 @@ public class PowderCatalogController : Controller
IColumbiaCatalogSyncService columbiaSyncService,
IPowderCatalogUpsertService upsertService,
IPlatformSettingsService platformSettings,
IConfiguration config,
ILogger<PowderCatalogController> logger)
{
_unitOfWork = unitOfWork;
@@ -40,6 +42,7 @@ public class PowderCatalogController : Controller
_columbiaSyncService = columbiaSyncService;
_upsertService = upsertService;
_platformSettings = platformSettings;
_config = config;
_logger = logger;
}
@@ -372,7 +375,8 @@ public class PowderCatalogController : Controller
PowderCatalogImportResult result;
try
{
result = await ImportJsonAsync(file, vendorName);
using var stream = file.OpenReadStream();
result = await ImportJsonAsync(stream, vendorName);
}
catch (Exception ex)
{
@@ -393,6 +397,67 @@ public class PowderCatalogController : Controller
return RedirectToAction(nameof(Index));
}
/// <summary>
/// Unattended catalog import for the offline scraper tool (e.g. PrismaticSync). Accepts the same
/// JSON scrape format in the request body, authenticated by a shared secret in the
/// <c>X-Import-Token</c> header (matched against <c>CatalogImport:Token</c>). The vendor name
/// comes from the <c>X-Vendor-Name</c> header. Runs through the same upsert as the manual upload.
/// Inert (401) until a token is configured.
/// </summary>
[HttpPost]
[AllowAnonymous]
[IgnoreAntiforgeryToken]
[RequestSizeLimit(50 * 1024 * 1024)] // 50 MB
public async Task<IActionResult> ImportApi()
{
var configuredToken = _config["CatalogImport:Token"];
if (string.IsNullOrWhiteSpace(configuredToken))
{
_logger.LogWarning("ImportApi called but no CatalogImport:Token is configured — rejecting.");
return Unauthorized(new { success = false, errorMessage = "Import API is not enabled." });
}
var providedToken = Request.Headers["X-Import-Token"].ToString();
if (!FixedTimeEquals(providedToken, configuredToken))
return Unauthorized(new { success = false, errorMessage = "Invalid import token." });
var vendorName = Request.Headers["X-Vendor-Name"].ToString();
if (string.IsNullOrWhiteSpace(vendorName))
vendorName = "Prismatic Powders";
try
{
var result = await ImportJsonAsync(Request.Body, vendorName);
_logger.LogInformation(
"ImportApi ({Vendor}): {Inserted} inserted, {Updated} updated, {Skipped} skipped, {Errors} errors.",
vendorName, result.Inserted, result.Updated, result.Skipped, result.Errors);
return Json(new
{
success = result.Success,
vendorName,
result.Inserted,
result.Updated,
result.Skipped,
result.Errors,
result.ErrorMessage
});
}
catch (Exception ex)
{
_logger.LogError(ex, "ImportApi failed for vendor {Vendor}", vendorName);
return StatusCode(500, new { success = false, errorMessage = "Import failed." });
}
}
/// <summary>Constant-time string comparison so token checks don't leak length/contents via timing.</summary>
private static bool FixedTimeEquals(string a, string b)
{
var ba = System.Text.Encoding.UTF8.GetBytes(a ?? string.Empty);
var bb = System.Text.Encoding.UTF8.GetBytes(b ?? string.Empty);
return System.Security.Cryptography.CryptographicOperations.FixedTimeEquals(ba, bb);
}
/// <summary>
/// AJAX endpoint used by the inventory form to search the catalog by SKU or color name.
/// SKU exact matches are ranked first; color name substring matches follow.
@@ -527,9 +592,8 @@ public class PowderCatalogController : Controller
}
}
private async Task<PowderCatalogImportResult> ImportJsonAsync(IFormFile file, string vendorName)
private async Task<PowderCatalogImportResult> ImportJsonAsync(Stream stream, string vendorName)
{
using var stream = file.OpenReadStream();
using var doc = await JsonDocument.ParseAsync(stream);
if (!doc.RootElement.TryGetProperty("results", out var resultsEl) ||
@@ -68,8 +68,7 @@ public class PricingTiersController : Controller
return View(dto);
// Check for duplicate name
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var existing = await _unitOfWork.PricingTiers.FindAsync(t => t.CompanyId == companyId && t.TierName == dto.TierName);
var existing = await _unitOfWork.PricingTiers.FindAsync(t => t.TierName == dto.TierName);
if (existing.Any())
{
ModelState.AddModelError(nameof(dto.TierName), "A tier with this name already exists.");
@@ -112,9 +111,8 @@ public class PricingTiersController : Controller
if (entity == null) return NotFound();
// Check for duplicate name (excluding this record)
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var duplicate = await _unitOfWork.PricingTiers.FindAsync(
t => t.CompanyId == companyId && t.TierName == dto.TierName && t.Id != dto.Id);
t => t.TierName == dto.TierName && t.Id != dto.Id);
if (duplicate.Any())
{
ModelState.AddModelError(nameof(dto.TierName), "A tier with this name already exists.");
@@ -140,8 +138,7 @@ public class PricingTiersController : Controller
if (entity == null) return NotFound();
// Block delete if customers are assigned to this tier
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var assignedCustomers = await _unitOfWork.Customers.FindAsync(c => c.CompanyId == companyId && c.PricingTierId == id);
var assignedCustomers = await _unitOfWork.Customers.FindAsync(c => c.PricingTierId == id);
if (assignedCustomers.Any())
{
TempData["ErrorMessage"] = $"Cannot delete '{entity.TierName}' — {assignedCustomers.Count()} customer(s) are assigned to it. Reassign them first.";
@@ -302,9 +302,6 @@ public class QuotesController : Controller
var quoteDto = _mapper.Map<QuoteDto>(quote);
// Bank/asset accounts the deposit can land in (deposit modal dropdown)
ViewBag.DepositAccounts = await AccountingDropdownHelper.LoadDepositAccountsAsync(_unitOfWork, companyId);
// Get customer info if exists
if (quote.CustomerId.HasValue)
{
@@ -3428,7 +3425,7 @@ public class QuotesController : Controller
try
{
var powders = await _unitOfWork.InventoryItems.FindAsync(i =>
i.CompanyId == companyId && i.Category != null && i.Category.ToLower().Contains("powder") && i.UnitCost > 0);
i.Category != null && i.Category.ToLower().Contains("powder") && i.UnitCost > 0);
avgPowderCost = powders.Any() ? powders.Average(p => p.UnitCost) : 8m;
}
catch
@@ -3522,7 +3519,7 @@ public class QuotesController : Controller
try
{
var powders = await _unitOfWork.InventoryItems.FindAsync(i =>
i.CompanyId == companyId && i.Category != null && i.Category.ToLower().Contains("powder") && i.UnitCost > 0);
i.Category != null && i.Category.ToLower().Contains("powder") && i.UnitCost > 0);
avgPowderCost = powders.Any() ? powders.Average(p => p.UnitCost) : 8m;
}
catch { avgPowderCost = 8m; }
@@ -3617,7 +3614,7 @@ public class QuotesController : Controller
// Pull recent accepted predictions (user didn't override) as few-shot calibration examples
var allPredictions = await _unitOfWork.AiItemPredictions.FindAsync(
p => p.CompanyId == companyId && !p.UserOverrodeEstimate && p.PredictedSurfaceAreaSqFt > 0 && p.PredictedUnitPrice > 0);
p => !p.UserOverrodeEstimate && p.PredictedSurfaceAreaSqFt > 0 && p.PredictedUnitPrice > 0);
context.AcceptedExamples = allPredictions
.OrderByDescending(p => p.CreatedAt)
@@ -3660,11 +3657,9 @@ public class QuotesController : Controller
{
var sqFtMin = sqFt * 0.4m;
var sqFtMax = sqFt * 2.5m;
var companyId = (await _userManager.GetUserAsync(User))?.CompanyId ?? 0;
var matches = await _unitOfWork.JobItems.FindAsync(
ji => ji.CompanyId == companyId
&& ji.Complexity == complexity
ji => ji.Complexity == complexity
&& ji.SurfaceAreaSqFt >= sqFtMin
&& ji.SurfaceAreaSqFt <= sqFtMax
&& ji.UnitPrice > 0
@@ -3672,7 +3667,7 @@ public class QuotesController : Controller
var jobIds = matches.Select(ji => ji.JobId).Distinct().ToList();
var completedStatusIds = (await _unitOfWork.JobStatusLookups.FindAsync(
s => s.CompanyId == companyId && (s.StatusCode == AppConstants.StatusCodes.Job.Completed || s.StatusCode == AppConstants.StatusCodes.Job.Delivered)))
s => s.StatusCode == AppConstants.StatusCodes.Job.Completed || s.StatusCode == AppConstants.StatusCodes.Job.Delivered))
.Select(s => s.Id).ToHashSet();
var completedJobs = await _unitOfWork.Jobs.FindAsync(
j => jobIds.Contains(j.Id) && completedStatusIds.Contains(j.JobStatusId));
@@ -590,8 +590,7 @@ public class ReportsController : Controller
// === POWDER USAGE ANALYTICS ===
var powderTransactions = (await _unitOfWork.InventoryTransactions
.FindAsync(t => t.CompanyId == companyId
&& t.TransactionType == InventoryTransactionType.JobUsage
.FindAsync(t => t.TransactionType == InventoryTransactionType.JobUsage
&& t.TransactionDate >= startDate,
false,
t => t.InventoryItem))
@@ -1251,20 +1250,6 @@ public class ReportsController : Controller
return View(dto);
}
/// <summary>
/// Balance reconciliation diagnostic: each account's stored CurrentBalance vs its recomputed ledger
/// balance, plus AR/AP subledger totals vs their control accounts. Read-only; surfaces drift in the
/// denormalized balances without changing any posting. Gated behind <see cref="AllowAccounting"/>.
/// </summary>
// GET: /Reports/Reconciliation
public async Task<IActionResult> Reconciliation()
{
if (!AllowAccounting()) return RedirectToAction(nameof(Landing));
var companyId = int.TryParse(User.FindFirst("CompanyId")?.Value, out var cid) ? cid : 0;
var dto = await _financialReports.GetBalanceReconciliationAsync(companyId);
return View(dto);
}
/// <summary>
/// PDF export of the Trial Balance report. Same inline/attachment pattern as other PDF actions.
/// Gated behind <see cref="AllowAccounting"/>.
@@ -2513,7 +2498,7 @@ public class ReportsController : Controller
var reportYear = year ?? DateTime.Now.Year;
// Load all budgets for the year for the selector
var allBudgets = (await _unitOfWork.Budgets.FindAsync(b => b.CompanyId == companyId && b.FiscalYear == reportYear))
var allBudgets = (await _unitOfWork.Budgets.FindAsync(b => b.FiscalYear == reportYear))
.OrderBy(b => b.Name).ToList();
Core.Entities.Budget? budget = null;
@@ -2521,10 +2506,10 @@ public class ReportsController : Controller
budget = await _unitOfWork.Budgets.GetByIdAsync(budgetId.Value, false, b => b.Lines);
budget ??= (await _unitOfWork.Budgets.FindAsync(
b => b.CompanyId == companyId && b.FiscalYear == reportYear && b.IsDefault, false, b => b.Lines)).FirstOrDefault();
b => b.FiscalYear == reportYear && b.IsDefault, false, b => b.Lines)).FirstOrDefault();
budget ??= (await _unitOfWork.Budgets.FindAsync(
b => b.CompanyId == companyId && b.FiscalYear == reportYear, false, b => b.Lines)).FirstOrDefault();
b => b.FiscalYear == reportYear, false, b => b.Lines)).FirstOrDefault();
ViewBag.ReportYear = reportYear;
ViewBag.Budget = budget;
@@ -2559,7 +2544,7 @@ public class ReportsController : Controller
// Load account metadata for budget lines
var accountIds = budget.Lines.Select(l => l.AccountId).Distinct().ToList();
var accounts = (await _unitOfWork.Accounts.FindAsync(a => a.CompanyId == budget.CompanyId && accountIds.Contains(a.Id)))
var accounts = (await _unitOfWork.Accounts.FindAsync(a => accountIds.Contains(a.Id)))
.ToDictionary(a => a.Id);
var rows = new List<BudgetVsActualRow>();
@@ -2600,7 +2585,7 @@ public class ReportsController : Controller
var periodEnd = new DateTime(reportYear, 12, 31, 23, 59, 59, DateTimeKind.Utc);
// Load 1099-eligible vendors
var vendors = (await _unitOfWork.Vendors.FindAsync(v => v.CompanyId == companyId && v.Is1099Vendor)).ToList();
var vendors = (await _unitOfWork.Vendors.FindAsync(v => v.Is1099Vendor)).ToList();
var rows = new List<Vendor1099Row>();
@@ -134,8 +134,7 @@ public class TaxRatesController : Controller
/// </summary>
private async Task ClearOtherDefaultsAsync(int exceptId)
{
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var others = await _unitOfWork.TaxRates.FindAsync(r => r.CompanyId == companyId && r.IsDefault && r.Id != exceptId);
var others = await _unitOfWork.TaxRates.FindAsync(r => r.IsDefault && r.Id != exceptId);
foreach (var r in others)
r.IsDefault = false;
}
@@ -3,21 +3,16 @@ using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Rendering;
using Microsoft.EntityFrameworkCore;
using PowderCoating.Application.DTOs.Import;
using PowderCoating.Application.Interfaces;
using PowderCoating.Core.Entities;
using PowderCoating.Core.Enums;
using PowderCoating.Core.Interfaces;
using PowderCoating.Infrastructure.Data;
using PowderCoating.Shared.Constants;
using System.Security.Claims;
namespace PowderCoating.Web.Controllers;
// Bulk import/export + QuickBooks migration tools — gated to the financial-management
// permission so low-privilege roles (ReadOnly/Employee/ShopFloor) can't export or
// import company data. (Audit #3, 2026-06-20.)
[Authorize(Policy = AppConstants.Policies.CanManageInvoices)]
[Authorize]
public class ToolsController : Controller
{
private readonly IUnitOfWork _unitOfWork;
@@ -1399,53 +1394,6 @@ public class ToolsController : Controller
}
}
/// <summary>
/// Bulk-imports invoice line items from a native CSV file. Lines are matched to their parent
/// invoice by InvoiceNumber and revenue accounts resolved by number. Run after the invoice import.
/// </summary>
// POST: Tools/CsvImportInvoiceItems
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> CsvImportInvoiceItems(IFormFile file)
{
try
{
var companyId = _tenantContext.GetCurrentCompanyId();
if (companyId == null)
return Json(new { success = false, message = "Your account is not associated with a company." });
if (file == null || file.Length == 0)
return Json(new { success = false, message = "No file provided or file is empty." });
if (!file.FileName.EndsWith(".csv", StringComparison.OrdinalIgnoreCase))
return Json(new { success = false, message = "Only CSV files are allowed." });
_logger.LogInformation("User {UserName} importing invoice items from CSV {FileName} for company {CompanyId}",
User.Identity?.Name, file.FileName, companyId);
using var stream = file.OpenReadStream();
var result = await _csvImportService.ImportInvoiceItemsAsync(stream, companyId.Value);
await LogCsvImportAsync("InvoiceItems", file.FileName, result);
return Json(new
{
success = result.Success,
message = result.Summary,
successCount = result.SuccessCount,
skippedCount = result.SkippedCount,
errorCount = result.ErrorCount,
totalRows = result.TotalRows,
errors = result.Errors,
warnings = result.Warnings
});
}
catch (Exception ex)
{
_logger.LogError(ex, "Error importing invoice items from CSV");
return Json(new { success = false, message = $"An error occurred during import: {ex.Message}" });
}
}
/// <summary>
/// Downloads a blank CSV template for the native payment bulk import.
/// Columns match the native ExportPaymentsCsv output for round-trip compatibility.
@@ -1458,90 +1406,6 @@ public class ToolsController : Controller
return File(csvBytes, "text/csv", "payment_import_template.csv");
}
/// <summary>Downloads a blank CSV template for the invoice line-item bulk import.</summary>
// GET: Tools/DownloadInvoiceItemTemplate
[HttpGet]
public IActionResult DownloadInvoiceItemTemplate()
{
var csvBytes = _csvImportService.GenerateInvoiceItemTemplate();
return File(csvBytes, "text/csv", "invoice_item_import_template.csv");
}
// POST: Tools/CsvImportBills — vendor bill headers (vendor by name, AP account by number).
[HttpPost]
[ValidateAntiForgeryToken]
public Task<IActionResult> CsvImportBills(IFormFile file)
=> RunCsvImport(file, "Bills", _csvImportService.ImportBillsAsync);
// POST: Tools/CsvImportBillLineItems — bill lines (run after bills).
[HttpPost]
[ValidateAntiForgeryToken]
public Task<IActionResult> CsvImportBillLineItems(IFormFile file)
=> RunCsvImport(file, "BillLineItems", _csvImportService.ImportBillLineItemsAsync);
// POST: Tools/CsvImportDeposits — customer deposits (customer by name, bank account by number).
[HttpPost]
[ValidateAntiForgeryToken]
public Task<IActionResult> CsvImportDeposits(IFormFile file)
=> RunCsvImport(file, "Deposits", _csvImportService.ImportDepositsAsync);
// POST: Tools/CsvImportJournalEntries — journal entry headers.
[HttpPost]
[ValidateAntiForgeryToken]
public Task<IActionResult> CsvImportJournalEntries(IFormFile file)
=> RunCsvImport(file, "JournalEntries", _csvImportService.ImportJournalEntriesAsync);
// POST: Tools/CsvImportJournalEntryLines — journal entry lines (run after journal entries).
[HttpPost]
[ValidateAntiForgeryToken]
public Task<IActionResult> CsvImportJournalEntryLines(IFormFile file)
=> RunCsvImport(file, "JournalEntryLines", _csvImportService.ImportJournalEntryLinesAsync);
/// <summary>
/// Shared plumbing for the accounting CSV imports: validates the upload, resolves the company,
/// runs the given import function, logs it, and returns the standard JSON result shape.
/// </summary>
private async Task<IActionResult> RunCsvImport(IFormFile file, string label,
Func<Stream, int, Task<CsvImportResultDto>> import)
{
try
{
var companyId = _tenantContext.GetCurrentCompanyId();
if (companyId == null)
return Json(new { success = false, message = "Your account is not associated with a company." });
if (file == null || file.Length == 0)
return Json(new { success = false, message = "No file provided or file is empty." });
if (!file.FileName.EndsWith(".csv", StringComparison.OrdinalIgnoreCase))
return Json(new { success = false, message = "Only CSV files are allowed." });
_logger.LogInformation("User {UserName} importing {Label} from CSV {FileName} for company {CompanyId}",
User.Identity?.Name, label, file.FileName, companyId);
using var stream = file.OpenReadStream();
var result = await import(stream, companyId.Value);
await LogCsvImportAsync(label, file.FileName, result);
return Json(new
{
success = result.Success,
message = result.Summary,
successCount = result.SuccessCount,
skippedCount = result.SkippedCount,
errorCount = result.ErrorCount,
totalRows = result.TotalRows,
errors = result.Errors,
warnings = result.Warnings
});
}
catch (Exception ex)
{
_logger.LogError(ex, "Error importing {Label} from CSV", label);
return Json(new { success = false, message = $"An error occurred during import: {ex.Message}" });
}
}
/// <summary>
/// Bulk-imports payment records from a native CSV file. Invoices are resolved by InvoiceNumber.
/// Duplicate payments (same invoice + date + amount) are skipped. Updates the invoice AmountPaid
@@ -2180,7 +2044,7 @@ public class ToolsController : Controller
}
// 11. Invoices
var invoices = await _unitOfWork.Invoices.FindAsync(i => i.CompanyId == companyId.Value, false, i => i.Customer, i => i.Job, i => i.InvoiceItems);
var invoices = await _unitOfWork.Invoices.FindAsync(i => i.CompanyId == companyId.Value, false, i => i.Customer, i => i.Job);
var invoicesCsv = GenerateInvoicesCsv(invoices);
var invoicesEntry = archive.CreateEntry($"invoices_{timestamp}.csv");
using (var entryStream = invoicesEntry.Open())
@@ -2199,17 +2063,6 @@ public class ToolsController : Controller
await writer.WriteAsync(accountsCsv);
}
// 12b. Invoice line items — one row per line, carrying the revenue account number so
// the invoice's revenue attribution survives an export/import round-trip.
var accountNumberById = accounts.ToDictionary(a => a.Id, a => a.AccountNumber ?? "");
var invoiceItemsCsv = GenerateInvoiceItemsCsv(invoices, accountNumberById);
var invoiceItemsEntry = archive.CreateEntry($"invoice_items_{timestamp}.csv");
using (var entryStream = invoiceItemsEntry.Open())
using (var writer = new System.IO.StreamWriter(entryStream))
{
await writer.WriteAsync(invoiceItemsCsv);
}
// 13. Expenses
var expenses = await _unitOfWork.Expenses.FindAsync(e => e.CompanyId == companyId.Value, false, e => e.ExpenseAccount, e => e.PaymentAccount, e => e.Vendor, e => e.Job);
var expensesCsv = GenerateExpensesCsv(expenses);
@@ -2221,7 +2074,7 @@ public class ToolsController : Controller
}
// 14. Payments
var payments = await _unitOfWork.Payments.FindAsync(p => p.CompanyId == companyId.Value, false, p => p.Invoice, p => p.DepositAccount);
var payments = await _unitOfWork.Payments.FindAsync(p => p.CompanyId == companyId.Value, false, p => p.Invoice);
var paymentsCsv = GeneratePaymentsCsv(payments);
var paymentsEntry = archive.CreateEntry($"payments_{timestamp}.csv");
using (var entryStream = paymentsEntry.Open())
@@ -2230,48 +2083,8 @@ public class ToolsController : Controller
await writer.WriteAsync(paymentsCsv);
}
// 15. Bills + bill line items (account/job by number, AP account, vendor by name)
var jobNumberById = jobs.Where(j => !string.IsNullOrEmpty(j.JobNumber)).ToDictionary(j => j.Id, j => j.JobNumber);
var bills = await _unitOfWork.Bills.FindAsync(b => b.CompanyId == companyId.Value, false, b => b.Vendor, b => b.APAccount, b => b.LineItems);
var billsEntry = archive.CreateEntry($"bills_{timestamp}.csv");
using (var entryStream = billsEntry.Open())
using (var writer = new System.IO.StreamWriter(entryStream))
{
await writer.WriteAsync(GenerateBillsCsv(bills));
}
var billLineItemsEntry = archive.CreateEntry($"bill_line_items_{timestamp}.csv");
using (var entryStream = billLineItemsEntry.Open())
using (var writer = new System.IO.StreamWriter(entryStream))
{
await writer.WriteAsync(GenerateBillLineItemsCsv(bills, accountNumberById, jobNumberById));
}
// 16. Deposits (customer by name, bank account + applied invoice by number)
var deposits = await _unitOfWork.Deposits.FindAsync(d => d.CompanyId == companyId.Value, false, d => d.Customer, d => d.AppliedToInvoice);
var depositsEntry = archive.CreateEntry($"deposits_{timestamp}.csv");
using (var entryStream = depositsEntry.Open())
using (var writer = new System.IO.StreamWriter(entryStream))
{
await writer.WriteAsync(GenerateDepositsCsv(deposits, accountNumberById));
}
// 17. Journal entries + lines (account by number, debit/credit)
var journalEntries = await _unitOfWork.JournalEntries.FindAsync(j => j.CompanyId == companyId.Value, false, j => j.Lines);
var journalEntriesEntry = archive.CreateEntry($"journal_entries_{timestamp}.csv");
using (var entryStream = journalEntriesEntry.Open())
using (var writer = new System.IO.StreamWriter(entryStream))
{
await writer.WriteAsync(GenerateJournalEntriesCsv(journalEntries));
}
var journalEntryLinesEntry = archive.CreateEntry($"journal_entry_lines_{timestamp}.csv");
using (var entryStream = journalEntryLinesEntry.Open())
using (var writer = new System.IO.StreamWriter(entryStream))
{
await writer.WriteAsync(GenerateJournalEntryLinesCsv(journalEntries, accountNumberById));
}
// 15. Purchase Orders
var purchaseOrders = await _unitOfWork.PurchaseOrders.FindAsync(po => po.CompanyId == companyId, false, po => po.Vendor);
var purchaseOrders = await _unitOfWork.PurchaseOrders.GetAllAsync(false, po => po.Vendor);
var purchaseOrdersCsv = GeneratePurchaseOrdersCsv(purchaseOrders);
var purchaseOrdersEntry = archive.CreateEntry($"purchase_orders_{timestamp}.csv");
using (var entryStream = purchaseOrdersEntry.Open())
@@ -2334,7 +2147,7 @@ public class ToolsController : Controller
return RedirectToAction(nameof(Index));
}
var customers = await _unitOfWork.Customers.FindAsync(c => c.CompanyId == companyId, false, c => c.PricingTier);
var customers = await _unitOfWork.Customers.GetAllAsync(false, c => c.PricingTier);
var csv = GenerateCustomersCsv(customers);
var fileName = $"customers_export_{DateTime.UtcNow:yyyyMMddHHmmss}.csv";
@@ -2368,7 +2181,7 @@ public class ToolsController : Controller
return RedirectToAction(nameof(Index));
}
var quotes = await _unitOfWork.Quotes.FindAsync(q => q.CompanyId == companyId, false, q => q.Customer, q => q.QuoteStatus);
var quotes = await _unitOfWork.Quotes.GetAllAsync(false, q => q.Customer, q => q.QuoteStatus);
var csv = GenerateQuotesCsv(quotes);
var fileName = $"quotes_export_{DateTime.UtcNow:yyyyMMddHHmmss}.csv";
@@ -2401,7 +2214,7 @@ public class ToolsController : Controller
return RedirectToAction(nameof(Index));
}
var jobs = await _unitOfWork.Jobs.FindAsync(j => j.CompanyId == companyId, false, j => j.Customer, j => j.JobStatus, j => j.JobPriority);
var jobs = await _unitOfWork.Jobs.GetAllAsync(false, j => j.Customer, j => j.JobStatus, j => j.JobPriority);
var csv = GenerateJobsCsv(jobs);
var fileName = $"jobs_export_{DateTime.UtcNow:yyyyMMddHHmmss}.csv";
@@ -2433,7 +2246,7 @@ public class ToolsController : Controller
return RedirectToAction(nameof(Index));
}
var appointments = await _unitOfWork.Appointments.FindAsync(a => a.CompanyId == companyId, false,
var appointments = await _unitOfWork.Appointments.GetAllAsync(false,
a => a.Customer, a => a.AppointmentType, a => a.AppointmentStatus);
var csv = GenerateAppointmentsCsv(appointments);
var fileName = $"appointments_export_{DateTime.UtcNow:yyyyMMddHHmmss}.csv";
@@ -2503,7 +2316,7 @@ public class ToolsController : Controller
return RedirectToAction(nameof(Index));
}
var inventoryItems = await _unitOfWork.InventoryItems.FindAsync(i => i.CompanyId == companyId, false, i => i.PrimaryVendor);
var inventoryItems = await _unitOfWork.InventoryItems.GetAllAsync(false, i => i.PrimaryVendor);
var csv = GenerateInventoryCsv(inventoryItems);
var fileName = $"inventory_export_{DateTime.UtcNow:yyyyMMddHHmmss}.csv";
@@ -2570,7 +2383,7 @@ public class ToolsController : Controller
return RedirectToAction(nameof(Index));
}
var maintenance = await _unitOfWork.MaintenanceRecords.FindAsync(m => m.CompanyId == companyId, false, m => m.Equipment);
var maintenance = await _unitOfWork.MaintenanceRecords.GetAllAsync(false, m => m.Equipment);
var csv = GenerateMaintenanceCsv(maintenance);
var fileName = $"maintenance_export_{DateTime.UtcNow:yyyyMMddHHmmss}.csv";
@@ -2658,7 +2471,7 @@ public class ToolsController : Controller
return RedirectToAction(nameof(Index));
}
var invoices = await _unitOfWork.Invoices.FindAsync(i => i.CompanyId == companyId, false, i => i.Customer, i => i.Job);
var invoices = await _unitOfWork.Invoices.GetAllAsync(false, i => i.Customer, i => i.Job);
var csv = GenerateInvoicesCsv(invoices);
var fileName = $"invoices_export_{DateTime.UtcNow:yyyyMMddHHmmss}.csv";
@@ -2691,7 +2504,7 @@ public class ToolsController : Controller
return RedirectToAction(nameof(Index));
}
var payments = await _unitOfWork.Payments.FindAsync(p => p.CompanyId == companyId, false, p => p.Invoice, p => p.DepositAccount);
var payments = await _unitOfWork.Payments.GetAllAsync(false, p => p.Invoice);
var csv = GeneratePaymentsCsv(payments);
var fileName = $"payments_export_{DateTime.UtcNow:yyyyMMddHHmmss}.csv";
@@ -2706,164 +2519,6 @@ public class ToolsController : Controller
}
}
/// <summary>
/// Exports all invoice line items for the current company as a CSV, keyed by parent invoice number
/// and carrying each line's revenue account number. Complements <see cref="ExportInvoicesCsv"/>
/// (which is header-only) so invoice detail and revenue attribution round-trip on re-import.
/// </summary>
// GET: Tools/ExportInvoiceItemsCsv
[HttpGet]
public async Task<IActionResult> ExportInvoiceItemsCsv()
{
try
{
var companyId = _tenantContext.GetCurrentCompanyId();
if (companyId == null)
{
TempData["ErrorMessage"] = "Your account is not associated with a company.";
return RedirectToAction(nameof(Index));
}
var invoices = await _unitOfWork.Invoices.FindAsync(i => i.CompanyId == companyId.Value, false, i => i.InvoiceItems);
var accounts = await _unitOfWork.Accounts.FindAsync(a => a.CompanyId == companyId.Value);
var accountNumberById = accounts.ToDictionary(a => a.Id, a => a.AccountNumber ?? "");
var csv = GenerateInvoiceItemsCsv(invoices, accountNumberById);
var fileName = $"invoice_items_export_{DateTime.UtcNow:yyyyMMddHHmmss}.csv";
await LogExportAsync("InvoiceItems", $"CSV export ({invoices.Sum(i => i.InvoiceItems.Count)} line items)");
return File(System.Text.Encoding.UTF8.GetBytes(csv), "text/csv", fileName);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error exporting invoice items to CSV");
TempData["ErrorMessage"] = "An error occurred while exporting invoice items.";
return RedirectToAction(nameof(Index));
}
}
/// <summary>Exports vendor bill headers (vendor by name, AP account by number) as CSV.</summary>
// GET: Tools/ExportBillsCsv
[HttpGet]
public async Task<IActionResult> ExportBillsCsv()
{
try
{
var companyId = _tenantContext.GetCurrentCompanyId();
if (companyId == null) { TempData["ErrorMessage"] = "Your account is not associated with a company."; return RedirectToAction(nameof(Index)); }
var bills = await _unitOfWork.Bills.FindAsync(b => b.CompanyId == companyId.Value, false, b => b.Vendor, b => b.APAccount);
var csv = GenerateBillsCsv(bills);
await LogExportAsync("Bills", $"CSV export ({bills.Count()} records)");
return File(System.Text.Encoding.UTF8.GetBytes(csv), "text/csv", $"bills_export_{DateTime.UtcNow:yyyyMMddHHmmss}.csv");
}
catch (Exception ex)
{
_logger.LogError(ex, "Error exporting bills to CSV");
TempData["ErrorMessage"] = "An error occurred while exporting bills.";
return RedirectToAction(nameof(Index));
}
}
/// <summary>Exports vendor bill line items (account/job by number) as CSV.</summary>
// GET: Tools/ExportBillLineItemsCsv
[HttpGet]
public async Task<IActionResult> ExportBillLineItemsCsv()
{
try
{
var companyId = _tenantContext.GetCurrentCompanyId();
if (companyId == null) { TempData["ErrorMessage"] = "Your account is not associated with a company."; return RedirectToAction(nameof(Index)); }
var bills = await _unitOfWork.Bills.FindAsync(b => b.CompanyId == companyId.Value, false, b => b.LineItems);
var accounts = await _unitOfWork.Accounts.FindAsync(a => a.CompanyId == companyId.Value);
var jobs = await _unitOfWork.Jobs.FindAsync(j => j.CompanyId == companyId.Value);
var accountNumberById = accounts.ToDictionary(a => a.Id, a => a.AccountNumber ?? "");
var jobNumberById = jobs.Where(j => !string.IsNullOrEmpty(j.JobNumber)).ToDictionary(j => j.Id, j => j.JobNumber);
var csv = GenerateBillLineItemsCsv(bills, accountNumberById, jobNumberById);
await LogExportAsync("BillLineItems", $"CSV export ({bills.Sum(b => b.LineItems.Count)} line items)");
return File(System.Text.Encoding.UTF8.GetBytes(csv), "text/csv", $"bill_line_items_export_{DateTime.UtcNow:yyyyMMddHHmmss}.csv");
}
catch (Exception ex)
{
_logger.LogError(ex, "Error exporting bill line items to CSV");
TempData["ErrorMessage"] = "An error occurred while exporting bill line items.";
return RedirectToAction(nameof(Index));
}
}
/// <summary>Exports customer deposits (customer by name, bank account + applied invoice by number) as CSV.</summary>
// GET: Tools/ExportDepositsCsv
[HttpGet]
public async Task<IActionResult> ExportDepositsCsv()
{
try
{
var companyId = _tenantContext.GetCurrentCompanyId();
if (companyId == null) { TempData["ErrorMessage"] = "Your account is not associated with a company."; return RedirectToAction(nameof(Index)); }
var deposits = await _unitOfWork.Deposits.FindAsync(d => d.CompanyId == companyId.Value, false, d => d.Customer, d => d.AppliedToInvoice);
var accounts = await _unitOfWork.Accounts.FindAsync(a => a.CompanyId == companyId.Value);
var accountNumberById = accounts.ToDictionary(a => a.Id, a => a.AccountNumber ?? "");
var csv = GenerateDepositsCsv(deposits, accountNumberById);
await LogExportAsync("Deposits", $"CSV export ({deposits.Count()} records)");
return File(System.Text.Encoding.UTF8.GetBytes(csv), "text/csv", $"deposits_export_{DateTime.UtcNow:yyyyMMddHHmmss}.csv");
}
catch (Exception ex)
{
_logger.LogError(ex, "Error exporting deposits to CSV");
TempData["ErrorMessage"] = "An error occurred while exporting deposits.";
return RedirectToAction(nameof(Index));
}
}
/// <summary>Exports journal entry headers as CSV. Lines export separately.</summary>
// GET: Tools/ExportJournalEntriesCsv
[HttpGet]
public async Task<IActionResult> ExportJournalEntriesCsv()
{
try
{
var companyId = _tenantContext.GetCurrentCompanyId();
if (companyId == null) { TempData["ErrorMessage"] = "Your account is not associated with a company."; return RedirectToAction(nameof(Index)); }
var entries = await _unitOfWork.JournalEntries.FindAsync(j => j.CompanyId == companyId.Value);
var csv = GenerateJournalEntriesCsv(entries);
await LogExportAsync("JournalEntries", $"CSV export ({entries.Count()} records)");
return File(System.Text.Encoding.UTF8.GetBytes(csv), "text/csv", $"journal_entries_export_{DateTime.UtcNow:yyyyMMddHHmmss}.csv");
}
catch (Exception ex)
{
_logger.LogError(ex, "Error exporting journal entries to CSV");
TempData["ErrorMessage"] = "An error occurred while exporting journal entries.";
return RedirectToAction(nameof(Index));
}
}
/// <summary>Exports journal entry lines (account by number, debit/credit) as CSV.</summary>
// GET: Tools/ExportJournalEntryLinesCsv
[HttpGet]
public async Task<IActionResult> ExportJournalEntryLinesCsv()
{
try
{
var companyId = _tenantContext.GetCurrentCompanyId();
if (companyId == null) { TempData["ErrorMessage"] = "Your account is not associated with a company."; return RedirectToAction(nameof(Index)); }
var entries = await _unitOfWork.JournalEntries.FindAsync(j => j.CompanyId == companyId.Value, false, j => j.Lines);
var accounts = await _unitOfWork.Accounts.FindAsync(a => a.CompanyId == companyId.Value);
var accountNumberById = accounts.ToDictionary(a => a.Id, a => a.AccountNumber ?? "");
var csv = GenerateJournalEntryLinesCsv(entries, accountNumberById);
await LogExportAsync("JournalEntryLines", $"CSV export ({entries.Sum(e => e.Lines.Count)} lines)");
return File(System.Text.Encoding.UTF8.GetBytes(csv), "text/csv", $"journal_entry_lines_export_{DateTime.UtcNow:yyyyMMddHHmmss}.csv");
}
catch (Exception ex)
{
_logger.LogError(ex, "Error exporting journal entry lines to CSV");
TempData["ErrorMessage"] = "An error occurred while exporting journal entry lines.";
return RedirectToAction(nameof(Index));
}
}
/// <summary>
/// Exports all purchase orders for the current company as a CSV file, including the vendor
/// company name resolved via eager loading. PO status is written as its enum name.
@@ -2881,7 +2536,7 @@ public class ToolsController : Controller
return RedirectToAction(nameof(Index));
}
var purchaseOrders = await _unitOfWork.PurchaseOrders.FindAsync(po => po.CompanyId == companyId, false, po => po.Vendor);
var purchaseOrders = await _unitOfWork.PurchaseOrders.GetAllAsync(false, po => po.Vendor);
var csv = GeneratePurchaseOrdersCsv(purchaseOrders);
var fileName = $"purchase_orders_export_{DateTime.UtcNow:yyyyMMddHHmmss}.csv";
@@ -4318,35 +3973,6 @@ public class ToolsController : Controller
return sb.ToString();
}
/// <summary>
/// Builds a CSV of invoice line items — one row per item across all the given invoices. The parent
/// invoice number and the line's revenue account number (resolved from <paramref name="accountNumberById"/>)
/// are written so revenue attribution survives an export/import round-trip. Rows are emitted in
/// DisplayOrder within each invoice.
/// </summary>
private string GenerateInvoiceItemsCsv(IEnumerable<Core.Entities.Invoice> invoices, IReadOnlyDictionary<int, string> accountNumberById)
{
var sb = new System.Text.StringBuilder();
sb.AppendLine("InvoiceNumber,Description,Quantity,UnitPrice,TotalPrice,ColorName,RevenueAccountNumber,DisplayOrder,Notes");
foreach (var invoice in invoices)
{
foreach (var item in invoice.InvoiceItems.OrderBy(it => it.DisplayOrder))
{
var revenueAccountNumber = item.RevenueAccountId.HasValue
&& accountNumberById.TryGetValue(item.RevenueAccountId.Value, out var num)
? num : "";
sb.AppendLine($"{EscapeCsv(invoice.InvoiceNumber)},{EscapeCsv(item.Description)}," +
$"{item.Quantity},{item.UnitPrice},{item.TotalPrice}," +
$"{EscapeCsv(item.ColorName)},{EscapeCsv(revenueAccountNumber)}," +
$"{item.DisplayOrder},{EscapeCsv(item.Notes)}");
}
}
return sb.ToString();
}
/// <summary>
/// Builds a CSV string for the given invoice payment records. The parent invoice number is
/// resolved from the eagerly loaded <c>Invoice</c> navigation property. PaymentMethod is
@@ -4355,111 +3981,13 @@ public class ToolsController : Controller
private string GeneratePaymentsCsv(IEnumerable<Core.Entities.Payment> payments)
{
var sb = new System.Text.StringBuilder();
sb.AppendLine("InvoiceNumber,Amount,PaymentDate,PaymentMethod,DepositAccountNumber,Reference,Notes");
sb.AppendLine("InvoiceNumber,Amount,PaymentDate,PaymentMethod,Reference,Notes");
foreach (var payment in payments)
{
sb.AppendLine($"{EscapeCsv(payment.Invoice?.InvoiceNumber)}," +
$"{payment.Amount},{payment.PaymentDate:yyyy-MM-dd}," +
$"{payment.PaymentMethod},{EscapeCsv(payment.DepositAccount?.AccountNumber)}," +
$"{EscapeCsv(payment.Reference)},{EscapeCsv(payment.Notes)}");
}
return sb.ToString();
}
/// <summary>Builds the vendor bill header CSV — vendor by name, AP account by number.</summary>
private string GenerateBillsCsv(IEnumerable<Core.Entities.Bill> bills)
{
var sb = new System.Text.StringBuilder();
sb.AppendLine("BillNumber,VendorInvoiceNumber,VendorName,APAccountNumber,BillDate,DueDate,Status,Terms,Memo,SubTotal,TaxPercent,TaxAmount,Total,AmountPaid");
foreach (var bill in bills)
{
sb.AppendLine($"{EscapeCsv(bill.BillNumber)},{EscapeCsv(bill.VendorInvoiceNumber)}," +
$"{EscapeCsv(bill.Vendor?.CompanyName)},{EscapeCsv(bill.APAccount?.AccountNumber)}," +
$"{bill.BillDate:yyyy-MM-dd},{bill.DueDate?.ToString("yyyy-MM-dd")},{bill.Status}," +
$"{EscapeCsv(bill.Terms)},{EscapeCsv(bill.Memo)}," +
$"{bill.SubTotal},{bill.TaxPercent},{bill.TaxAmount},{bill.Total},{bill.AmountPaid}");
}
return sb.ToString();
}
/// <summary>Builds the bill line-item CSV — one row per line, account/job resolved by number.</summary>
private string GenerateBillLineItemsCsv(IEnumerable<Core.Entities.Bill> bills,
IReadOnlyDictionary<int, string> accountNumberById, IReadOnlyDictionary<int, string> jobNumberById)
{
var sb = new System.Text.StringBuilder();
sb.AppendLine("BillNumber,AccountNumber,JobNumber,Description,Quantity,UnitPrice,Amount,DisplayOrder");
foreach (var bill in bills)
{
foreach (var line in bill.LineItems.OrderBy(li => li.DisplayOrder))
{
var accountNumber = line.AccountId.HasValue && accountNumberById.TryGetValue(line.AccountId.Value, out var an) ? an : "";
var jobNumber = line.JobId.HasValue && jobNumberById.TryGetValue(line.JobId.Value, out var jn) ? jn : "";
sb.AppendLine($"{EscapeCsv(bill.BillNumber)},{EscapeCsv(accountNumber)},{EscapeCsv(jobNumber)}," +
$"{EscapeCsv(line.Description)},{line.Quantity},{line.UnitPrice},{line.Amount},{line.DisplayOrder}");
}
}
return sb.ToString();
}
/// <summary>Builds the customer deposit CSV — customer by name, bank account + applied invoice resolved.</summary>
private string GenerateDepositsCsv(IEnumerable<Core.Entities.Deposit> deposits, IReadOnlyDictionary<int, string> accountNumberById)
{
var sb = new System.Text.StringBuilder();
sb.AppendLine("ReceiptNumber,CustomerName,Amount,PaymentMethod,ReceivedDate,DepositAccountNumber,AppliedToInvoiceNumber,AppliedDate,Reference,Notes");
foreach (var deposit in deposits)
{
var customerName = deposit.Customer != null
? (!string.IsNullOrWhiteSpace(deposit.Customer.CompanyName)
? deposit.Customer.CompanyName
: $"{deposit.Customer.ContactFirstName} {deposit.Customer.ContactLastName}".Trim())
: "";
var depositAccountNumber = deposit.DepositAccountId.HasValue && accountNumberById.TryGetValue(deposit.DepositAccountId.Value, out var an) ? an : "";
sb.AppendLine($"{EscapeCsv(deposit.ReceiptNumber)},{EscapeCsv(customerName)},{deposit.Amount}," +
$"{deposit.PaymentMethod},{deposit.ReceivedDate:yyyy-MM-dd},{EscapeCsv(depositAccountNumber)}," +
$"{EscapeCsv(deposit.AppliedToInvoice?.InvoiceNumber)},{deposit.AppliedDate?.ToString("yyyy-MM-dd")}," +
$"{EscapeCsv(deposit.Reference)},{EscapeCsv(deposit.Notes)}");
}
return sb.ToString();
}
/// <summary>Builds the journal entry header CSV. Lines are exported separately.</summary>
private string GenerateJournalEntriesCsv(IEnumerable<Core.Entities.JournalEntry> entries)
{
var sb = new System.Text.StringBuilder();
sb.AppendLine("EntryNumber,EntryDate,Reference,Description,Status");
foreach (var entry in entries)
{
sb.AppendLine($"{EscapeCsv(entry.EntryNumber)},{entry.EntryDate:yyyy-MM-dd}," +
$"{EscapeCsv(entry.Reference)},{EscapeCsv(entry.Description)},{entry.Status}");
}
return sb.ToString();
}
/// <summary>Builds the journal entry line CSV — one row per debit/credit line, account by number.</summary>
private string GenerateJournalEntryLinesCsv(IEnumerable<Core.Entities.JournalEntry> entries, IReadOnlyDictionary<int, string> accountNumberById)
{
var sb = new System.Text.StringBuilder();
sb.AppendLine("EntryNumber,AccountNumber,DebitAmount,CreditAmount,Description,LineOrder");
foreach (var entry in entries)
{
foreach (var line in entry.Lines.OrderBy(l => l.LineOrder))
{
var accountNumber = accountNumberById.TryGetValue(line.AccountId, out var an) ? an : "";
sb.AppendLine($"{EscapeCsv(entry.EntryNumber)},{EscapeCsv(accountNumber)}," +
$"{line.DebitAmount},{line.CreditAmount},{EscapeCsv(line.Description)},{line.LineOrder}");
}
$"{payment.PaymentMethod},{EscapeCsv(payment.Reference)},{EscapeCsv(payment.Notes)}");
}
return sb.ToString();
@@ -4615,7 +4143,7 @@ public class ToolsController : Controller
return RedirectToAction(nameof(Index));
}
var expenses = await _unitOfWork.Expenses.FindAsync(e => e.CompanyId == companyId, false,
var expenses = await _unitOfWork.Expenses.GetAllAsync(false,
e => e.ExpenseAccount,
e => e.PaymentAccount,
e => e.Vendor,
@@ -132,7 +132,7 @@ public class VendorCreditsController : Controller
.Select(l => l.AccountId!.Value)
.Distinct()
.ToList();
var accounts = await _unitOfWork.Accounts.FindAsync(a => a.CompanyId == vc.CompanyId && accountIds.Contains(a.Id));
var accounts = await _unitOfWork.Accounts.FindAsync(a => accountIds.Contains(a.Id));
ViewBag.AccountMap = accounts.ToDictionary(a => a.Id, a => $"{a.AccountNumber} {a.Name}");
// Load bills referenced by applications
@@ -357,9 +357,8 @@ public class VendorCreditsController : Controller
private async Task PopulateDropdownsAsync()
{
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var vendors = await _unitOfWork.Vendors.FindAsync(v => v.CompanyId == companyId && v.IsActive);
var accounts = await _unitOfWork.Accounts.FindAsync(a => a.CompanyId == companyId && a.IsActive);
var vendors = await _unitOfWork.Vendors.FindAsync(v => v.IsActive);
var accounts = await _unitOfWork.Accounts.FindAsync(a => a.IsActive);
ViewBag.VendorList = vendors
.OrderBy(v => v.CompanyName)
@@ -463,9 +463,8 @@ public class VendorsController : Controller
private async Task PopulateExpenseAccountsAsync()
{
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var accounts = (await _unitOfWork.Accounts.FindAsync(
a => a.CompanyId == companyId && a.IsActive && (a.AccountType == AccountType.Expense ||
a => a.IsActive && (a.AccountType == AccountType.Expense ||
a.AccountType == AccountType.CostOfGoods ||
a.AccountType == AccountType.Asset)))
.OrderBy(a => a.AccountNumber)
@@ -1,28 +0,0 @@
using PowderCoating.Core.Enums;
using PowderCoating.Core.Interfaces;
namespace PowderCoating.Web.Helpers;
/// <summary>
/// Server-side validation for account selections that must be a "money" account — a payment
/// source (bill payment), deposit target, or reconcilable account. The dropdowns already limit
/// the choices, so this is defense in depth against tampered or stale POSTs (e.g. an account
/// deleted/retyped between page load and submit): it rejects anything that isn't an active,
/// company-owned Asset or Liability account before a GL posting is made against it.
/// </summary>
internal static class AccountGuard
{
/// <summary>
/// Returns true when <paramref name="accountId"/> identifies an active account belonging to
/// <paramref name="companyId"/> whose top-level type is Asset or Liability. Filters CompanyId
/// explicitly (defense in depth alongside the global tenant filter).
/// </summary>
internal static async Task<bool> IsValidMoneyAccountAsync(IUnitOfWork unitOfWork, int? accountId, int companyId)
{
if (accountId == null) return false;
var account = await unitOfWork.Accounts.FirstOrDefaultAsync(
a => a.Id == accountId.Value && a.CompanyId == companyId && a.IsActive);
return account != null
&& (account.AccountType == AccountType.Asset || account.AccountType == AccountType.Liability);
}
}
@@ -17,27 +17,6 @@ internal static class AccountingDropdownHelper
/// Returns pre-projected SelectListItem collections so controllers avoid duplicating the
/// LINQ-to-SelectListItem transform.
/// </summary>
/// <summary>
/// Loads the accounts a customer deposit can land in — any active Asset or Liability
/// account for the company (filtered by parent AccountType, not sub-type, so accounts a
/// company classified differently still appear). Checking/Cash accounts sort to the top
/// as the usual choice. Used to populate the deposit modal's account dropdown on the Job
/// and Quote details pages. CompanyId is filtered explicitly (defense in depth).
/// </summary>
internal static async Task<List<SelectListItem>> LoadDepositAccountsAsync(IUnitOfWork unitOfWork, int companyId)
{
var accounts = await unitOfWork.Accounts.FindAsync(
a => a.CompanyId == companyId && a.IsActive
&& (a.AccountType == AccountType.Asset || a.AccountType == AccountType.Liability));
return accounts
.OrderByDescending(a => a.AccountSubType == AccountSubType.Checking ||
a.AccountSubType == AccountSubType.Cash)
.ThenBy(a => a.AccountNumber)
.Select(a => new SelectListItem($"{a.AccountNumber} {a.Name}", a.Id.ToString()))
.ToList();
}
internal static async Task<AccountingDropdowns> LoadAsync(IUnitOfWork unitOfWork)
{
var vendors = await unitOfWork.Vendors.FindAsync(v => v.IsActive);
@@ -71,21 +50,17 @@ internal static class AccountingDropdownHelper
.Select(a => new SelectListItem(accountLabel(a), a.Id.ToString()))
.ToList(),
// Filter by parent AccountType only — not sub-type. Companies classify their
// own accounts differently (e.g. a "Line of Credit" they treat as a payable),
// so listing every account of the right top-level type lets them pick what they
// actually use instead of silently hiding accounts on a sub-type mismatch.
ApAccounts = allAccounts
.Where(a => a.AccountType == AccountType.Liability)
.Where(a => a.AccountSubType == AccountSubType.AccountsPayable)
.OrderBy(a => a.AccountNumber)
.Select(a => new SelectListItem(accountLabel(a), a.Id.ToString()))
.ToList(),
// Payment sources span both Assets (cash/checking/savings) and Liabilities
// (credit cards, lines of credit), so include both top-level types.
BankAccounts = allAccounts
.Where(a => a.AccountType == AccountType.Asset ||
a.AccountType == AccountType.Liability)
.Where(a => a.AccountSubType == AccountSubType.Cash ||
a.AccountSubType == AccountSubType.Checking ||
a.AccountSubType == AccountSubType.Savings ||
a.AccountSubType == AccountSubType.CreditCard)
.OrderBy(a => a.AccountNumber)
.Select(a => new SelectListItem(accountLabel(a), a.Id.ToString()))
.ToList(),
@@ -678,12 +678,6 @@ public static class HelpKnowledgeBase
**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.
**Trial balance indicator (Chart of Accounts)**
The Chart of Accounts page shows a Trial Balance badge: "Balanced" when total debits equal total credits, otherwise how far off the books are (excess debits/credits). A non-zero value usually means opening balances were entered without an offsetting entry, or a one-sided posting. Run Recalculate Balances first; if it persists, review opening balances.
**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?**
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.
@@ -565,14 +565,7 @@ public class QuickBooksOnlineService
var parentName = name.Contains(':') ? name.Substring(0, name.LastIndexOf(':')).Trim() : null;
result.TotalRecords++;
// 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));
rows.Add((displayName, parentName, number, desc, MapQboAccountType(typeStr), MapQboDetailType(detailType)));
}
// Pass 1: upsert every account WITHOUT parent links so they all get IDs.
@@ -36,34 +36,16 @@
};
}
@{
var tbNet = (decimal)(ViewBag.TrialBalanceNet ?? 0m);
var tbBalanced = Math.Abs(tbNet) < 0.01m;
}
<div class="d-flex justify-content-between align-items-center mb-4 flex-wrap gap-2">
@if (Model.Any())
{
<div>
@if (tbBalanced)
{
<span class="badge bg-success-subtle text-success-emphasis border border-success-subtle py-2 px-3">
<i class="bi bi-check-circle me-1"></i>Trial balance: Balanced
</span>
}
else
{
<span class="badge bg-warning-subtle text-warning-emphasis border border-warning-subtle py-2 px-3"
title="Total debits minus total credits. A non-zero value usually means opening balances were entered without an offsetting entry, or a one-sided posting. Run Recalculate Balances; if it persists, review opening balances.">
<i class="bi bi-exclamation-triangle me-1"></i>Trial balance off by @tbNet.ToString("C") (@(tbNet > 0 ? "excess debits" : "excess credits"))
</span>
}
</div>
}
else
{
<div></div>
}
<div class="d-flex justify-content-end mb-4">
<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">
@Html.AntiForgeryToken()
<button type="button" id="btnRecalcBalances" class="btn btn-outline-secondary"
@@ -111,80 +93,6 @@
</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&apos;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 &mdash; 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 &amp; 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())
{
<div class="card shadow-sm border-0">
@@ -131,7 +131,7 @@
<div class="col-md-6 mb-3">
<label asp-for="RevenueAccountId" class="form-label"></label>
<select asp-for="RevenueAccountId" class="form-select" asp-items="ViewBag.RevenueAccounts">
<option value="">@((ViewBag.HasDefaultRevenueAccount ?? false) ? "(Default revenue account)" : "(None)")</option>
<option value="">(Default revenue account)</option>
</select>
<small class="form-text text-muted">Account credited when this item is invoiced.</small>
</div>
@@ -139,7 +139,7 @@
<label asp-for="CogsAccountId" class="form-label"></label>
<select asp-for="CogsAccountId" class="form-select" asp-items="ViewBag.CogsAccounts"
data-quick-add-url="/Accounts/Create?preSubType=40" data-quick-add-title="Add COGS Account">
<option value="">@((ViewBag.HasDefaultCogsAccount ?? false) ? "(Default COGS account)" : "(None)")</option>
<option value="">(Default COGS account)</option>
<option value="__new__">+ Add New Account&hellip;</option>
</select>
<small class="form-text text-muted">Account debited when materials are consumed.</small>
@@ -134,7 +134,7 @@
<div class="col-md-6 mb-3">
<label asp-for="RevenueAccountId" class="form-label"></label>
<select asp-for="RevenueAccountId" class="form-select" asp-items="ViewBag.RevenueAccounts">
<option value="">@((ViewBag.HasDefaultRevenueAccount ?? false) ? "(Default revenue account)" : "(None)")</option>
<option value="">(Default revenue account)</option>
</select>
<small class="form-text text-muted">Account credited when this item is invoiced.</small>
</div>
@@ -142,7 +142,7 @@
<label asp-for="CogsAccountId" class="form-label"></label>
<select asp-for="CogsAccountId" class="form-select" asp-items="ViewBag.CogsAccounts"
data-quick-add-url="/Accounts/Create?preSubType=40" data-quick-add-title="Add COGS Account">
<option value="">@((ViewBag.HasDefaultCogsAccount ?? false) ? "(Default COGS account)" : "(None)")</option>
<option value="">(Default COGS account)</option>
<option value="__new__">+ Add New Account&hellip;</option>
</select>
<small class="form-text text-muted">Account debited when materials are consumed.</small>
@@ -618,36 +618,6 @@
</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 &mdash; always present) -->
<div class="card shadow-sm border-danger mt-4">
<div class="card-header bg-light">
@@ -237,40 +237,6 @@
The chart of accounts is typically configured once during initial setup. You can add new accounts
at any time if your accounting needs expand.
</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&apos;t specify its own:
</p>
<ul class="mb-3">
<li class="mb-1"><strong>Revenue</strong> &mdash; invoice lines without a specific revenue account fall back to this one (and to account 4000 if you haven&apos;t set a default).</li>
<li class="mb-1"><strong>COGS</strong> and <strong>Inventory Asset</strong> &mdash; new inventory and catalog items are pre-filled with these, so you don&apos;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&apos;re used &mdash; 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>
<h3 class="h6 fw-semibold mt-3 mb-2">Trial Balance Indicator</h3>
<p>
The top of the Chart of Accounts page shows a <strong>Trial balance</strong> badge. When total
debits equal total credits it reads <em>Balanced</em>; otherwise it shows how far off the books
are (excess debits or credits). A non-zero value usually means opening balances were entered
without an offsetting entry, or a one-sided posting occurred. Run <strong>Recalculate Balances</strong>
first; if it persists, review your opening balances or ask your accountant.
</p>
<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>
<div>
@@ -17,10 +17,8 @@
<div class="card border-0 shadow-sm">
<div class="card-body p-4">
<form asp-action="Create" method="post" id="inventory-create-form">
<input type="hidden" asp-for="DuplicateOverrideInventoryItemId" id="duplicate-override-id" />
<form asp-action="Create" method="post">
<partial name="_ValidationSummary" />
<div id="inventory-duplicate-status" class="d-none mb-3" role="alert"></div>
<!-- Basic Information -->
<div class="mb-4">
@@ -375,7 +373,7 @@
<label asp-for="InventoryAccountId" class="form-label"></label>
<select asp-for="InventoryAccountId" class="form-select" asp-items="ViewBag.InventoryAccounts"
data-quick-add-url="/Accounts/Create?preSubType=4" data-quick-add-title="Add Inventory Account">
<option value="">@((ViewBag.HasDefaultInventoryAccount ?? false) ? "(Default inventory account)" : "(None)")</option>
<option value="">(Default inventory account)</option>
<option value="__new__">+ Add New Account&hellip;</option>
</select>
<small class="form-text text-muted">Asset account where inventory value is tracked (e.g., 1200 Inventory - Powder).</small>
@@ -384,7 +382,7 @@
<label asp-for="CogsAccountId" class="form-label"></label>
<select asp-for="CogsAccountId" class="form-select" asp-items="ViewBag.CogsAccounts"
data-quick-add-url="/Accounts/Create?preSubType=40" data-quick-add-title="Add COGS Account">
<option value="">@((ViewBag.HasDefaultCogsAccount ?? false) ? "(Default COGS account)" : "(None)")</option>
<option value="">(Default COGS account)</option>
<option value="__new__">+ Add New Account&hellip;</option>
</select>
<small class="form-text text-muted">Expense account debited when this material is consumed on a job.</small>
@@ -430,15 +428,16 @@
</div>
</div>
<partial name="_LabelScanModal" />
@if ((bool)(ViewBag.AiInventoryAssistEnabled ?? false))
{
<partial name="_LabelScanModal" />
}
@section Scripts {
<partial name="_ValidationScriptsPartial" />
<script>const inventoryFormIsCreate = true;</script>
<script src="~/js/inventory-vendor-match.js"></script>
<partial name="_InventoryColorFamilyScripts" />
<script src="~/js/inventory-catalog-lookup.js"></script>
<script src="~/js/inventory-duplicate-check.js"></script>
@if ((bool)(ViewBag.AiInventoryAssistEnabled ?? false))
{
<script src="~/js/inventory-label-scan.js"></script>
@@ -374,7 +374,7 @@
<label asp-for="InventoryAccountId" class="form-label"></label>
<select asp-for="InventoryAccountId" class="form-select" asp-items="ViewBag.InventoryAccounts"
data-quick-add-url="/Accounts/Create?preSubType=4" data-quick-add-title="Add Inventory Account">
<option value="">@((ViewBag.HasDefaultInventoryAccount ?? false) ? "(Default inventory account)" : "(None)")</option>
<option value="">(Default inventory account)</option>
<option value="__new__">+ Add New Account&hellip;</option>
</select>
<small class="form-text text-muted">Asset account where inventory value is tracked (e.g., 1200 Inventory - Powder).</small>
@@ -383,7 +383,7 @@
<label asp-for="CogsAccountId" class="form-label"></label>
<select asp-for="CogsAccountId" class="form-select" asp-items="ViewBag.CogsAccounts"
data-quick-add-url="/Accounts/Create?preSubType=40" data-quick-add-title="Add COGS Account">
<option value="">@((ViewBag.HasDefaultCogsAccount ?? false) ? "(Default COGS account)" : "(None)")</option>
<option value="">(Default COGS account)</option>
<option value="__new__">+ Add New Account&hellip;</option>
</select>
<small class="form-text text-muted">Expense account debited when this material is consumed on a job.</small>
@@ -448,14 +448,15 @@
</div>
</div>
<partial name="_LabelScanModal" />
@if ((bool)(ViewBag.AiInventoryAssistEnabled ?? false))
{
<partial name="_LabelScanModal" />
}
@section Scripts {
<partial name="_ValidationScriptsPartial" />
<script src="~/js/inventory-vendor-match.js"></script>
<partial name="_InventoryColorFamilyScripts" />
<script src="~/js/inventory-catalog-lookup.js"></script>
<script src="~/js/inventory-duplicate-check.js"></script>
@if ((bool)(ViewBag.AiInventoryAssistEnabled ?? false))
{
<script src="~/js/inventory-label-scan.js"></script>
@@ -195,15 +195,17 @@
function autoMatchVendor() {
if (!isCoatingCategory(categorySelect?.value)) return;
if (typeof window.matchInventoryVendor === 'function') {
window.matchInventoryVendor(vendorSel, manufacturerEl?.value, null);
}
if (!vendorSel || vendorSel.value) return; // don't overwrite an existing selection
const mfr = (manufacturerEl?.value?.trim() ?? '').toLowerCase();
if (!mfr) return;
const match = Array.from(vendorSel.options).find(o =>
o.text.toLowerCase().includes(mfr) || mfr.includes(o.text.toLowerCase().trim())
);
if (match) vendorSel.value = match.value;
}
if (manufacturerEl) {
// Use 'change' (fires on blur with the full value) rather than 'input' so partial
// mid-typing values like "P" don't trigger a wrong vendor pick.
manufacturerEl.addEventListener('change', autoMatchVendor);
manufacturerEl.addEventListener('input', autoMatchVendor);
}
if (colorNameEl) {
colorNameEl.addEventListener('input', autoComposeName);
@@ -419,15 +421,15 @@
aiFilledColorFamilies = true;
}
// Vendor: match on the Manufacturer field first (almost always populated and equal to
// the vendor for the shop's distributors); fall back to the AI's price-derived vendorName.
{
// Vendor: match by name (case-insensitive) against dropdown options
if (data.vendorName) {
const vendorSel = document.getElementById('field-vendor');
const mfrName = document.getElementById('field-manufacturer')?.value || data.manufacturer;
if (typeof window.matchInventoryVendor === 'function' &&
window.matchInventoryVendor(vendorSel, mfrName, data.vendorName, { force: forceRefill })) {
filled.push('Vendor');
aiFilledVendor = true;
if (vendorSel && (forceRefill || !vendorSel.value)) {
const needle = data.vendorName.toLowerCase();
const match = Array.from(vendorSel.options).find(o =>
o.text.toLowerCase().includes(needle) || needle.includes(o.text.toLowerCase().trim())
);
if (match) { vendorSel.value = match.value; filled.push('Vendor'); aiFilledVendor = true; }
}
}
@@ -1306,17 +1306,6 @@
<label class="form-label fw-semibold">Date Received <span class="text-danger">*</span></label>
<input type="date" class="form-control" id="depositDate" name="receivedDate" required value="@(DateTime.Today.ToString("yyyy-MM-dd"))" />
</div>
@{ var depositAccounts = ViewBag.DepositAccounts as List<Microsoft.AspNetCore.Mvc.Rendering.SelectListItem>; }
@if (depositAccounts != null && depositAccounts.Count > 0)
{
<div class="mb-3">
<label class="form-label fw-semibold">Deposit To</label>
<select class="form-select" id="depositAccount" name="depositAccountId" asp-items="depositAccounts">
<option value="">Default (first checking/cash account)</option>
</select>
<small class="form-text text-muted">Bank or asset account this deposit is recorded against.</small>
</div>
}
<div class="mb-3">
<label class="form-label">Reference (check #, card last 4, etc.)</label>
<input type="text" class="form-control" id="depositReference" name="reference" maxlength="200" />
@@ -1,72 +0,0 @@
@{
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>
}
@@ -1898,17 +1898,6 @@
<label class="form-label fw-semibold">Date Received <span class="text-danger">*</span></label>
<input type="date" class="form-control" id="depositDate" name="receivedDate" required value="@(DateTime.Today.ToString("yyyy-MM-dd"))" />
</div>
@{ var depositAccounts = ViewBag.DepositAccounts as List<Microsoft.AspNetCore.Mvc.Rendering.SelectListItem>; }
@if (depositAccounts != null && depositAccounts.Count > 0)
{
<div class="mb-3">
<label class="form-label fw-semibold">Deposit To</label>
<select class="form-select" id="depositAccount" name="depositAccountId" asp-items="depositAccounts">
<option value="">Default (first checking/cash account)</option>
</select>
<small class="form-text text-muted">Bank or asset account this deposit is recorded against.</small>
</div>
}
<div class="mb-3">
<label class="form-label">Reference (check #, card last 4, etc.)</label>
<input type="text" class="form-control" id="depositReference" name="reference" maxlength="200" />
@@ -220,14 +220,6 @@
<p>All active accounts with debit and credit balances &mdash; validates that your books are in balance.</p>
<div class="report-arrow">Open report <i class="bi bi-arrow-right"></i></div>
</a>
<a asp-controller="Reports" asp-action="Reconciliation" class="report-card">
<div class="report-card-icon" style="background:#f0fdf4;color:#15803d;">
<i class="bi bi-clipboard-check"></i>
</div>
<h5>Balance Reconciliation</h5>
<p>Stored account balances vs. the recomputed ledger, plus AR/AP subledger vs. control &mdash; surfaces any drift.</p>
<div class="report-arrow">Open report <i class="bi bi-arrow-right"></i></div>
</a>
<a asp-controller="Reports" asp-action="CashFlowStatement" class="report-card">
<div class="report-card-icon" style="background:#ecfeff;color:#0891b2;">
<i class="bi bi-water"></i>
@@ -1,105 +0,0 @@
@model PowderCoating.Application.DTOs.Accounting.BalanceReconciliationDto
@using PowderCoating.Core.Enums
@{
ViewData["Title"] = "Balance Reconciliation";
ViewData["PageIcon"] = "bi-clipboard-check";
}
<style>
@@media print { .no-print { display: none !important; } }
.drift { background: #fef2f2; }
</style>
<div class="d-flex align-items-center gap-2 mb-3 no-print">
<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">
As of @Model.AsOf.ToLocalTime().ToString("MMMM d, yyyy h:mm tt") &middot;
@if (Model.AllReconciled)
{
<span class="text-success fw-semibold"><i class="bi bi-check-circle me-1"></i>Everything reconciles</span>
}
else
{
<span class="text-danger fw-semibold">
<i class="bi bi-exclamation-triangle me-1"></i>@Model.DriftedAccounts.Count() account(s) drifted@(Model.ArReconciled ? "" : ", AR off")@(Model.ApReconciled ? "" : ", AP off")
</span>
}
</p>
</div>
<!-- Subledger vs control -->
<div class="row g-3 mb-3">
<div class="col-md-6">
<div class="card shadow-sm h-100 @(Model.ArReconciled ? "" : "border-danger")">
<div class="card-body">
<h6 class="text-muted mb-2">Accounts Receivable</h6>
<div class="d-flex justify-content-between"><span>GL control account</span><span>@Model.ArControlBalance.ToString("C")</span></div>
<div class="d-flex justify-content-between"><span>Customer subledger (sum)</span><span>@Model.ArSubledgerTotal.ToString("C")</span></div>
<hr class="my-2" />
<div class="d-flex justify-content-between fw-semibold @(Model.ArReconciled ? "text-success" : "text-danger")">
<span>Difference</span><span>@Model.ArDifference.ToString("C")</span>
</div>
</div>
</div>
</div>
<div class="col-md-6">
<div class="card shadow-sm h-100 @(Model.ApReconciled ? "" : "border-danger")">
<div class="card-body">
<h6 class="text-muted mb-2">Accounts Payable</h6>
<div class="d-flex justify-content-between"><span>GL control account</span><span>@Model.ApControlBalance.ToString("C")</span></div>
<div class="d-flex justify-content-between"><span>Vendor subledger (sum)</span><span>@Model.ApSubledgerTotal.ToString("C")</span></div>
<hr class="my-2" />
<div class="d-flex justify-content-between fw-semibold @(Model.ApReconciled ? "text-success" : "text-danger")">
<span>Difference</span><span>@Model.ApDifference.ToString("C")</span>
</div>
</div>
</div>
</div>
</div>
<!-- Stored vs recomputed ledger, per account -->
<div class="card shadow-sm">
<div class="card-body">
<div class="d-flex align-items-center mb-2">
<h6 class="mb-0">Stored balance vs recomputed ledger</h6>
<span class="ms-2 badge bg-secondary">@Model.AccountLines.Count accounts</span>
</div>
<p class="text-muted small mb-3">
A difference means <code>Account.CurrentBalance</code> has drifted from what the source documents
recompute to. Running <strong>Accounts &rarr; Recalculate Balances</strong> resets the stored value
to the ledger value.
</p>
<div class="table-responsive">
<table class="table table-sm align-middle">
<thead>
<tr>
<th>#</th><th>Account</th><th>Type</th>
<th class="text-end">Stored</th><th class="text-end">Ledger</th><th class="text-end">Difference</th>
</tr>
</thead>
<tbody>
@foreach (var l in Model.AccountLines)
{
<tr class="@(l.IsReconciled ? "" : "drift")">
<td>@l.AccountNumber</td>
<td>@l.AccountName</td>
<td><span class="text-muted small">@l.AccountType</span></td>
<td class="text-end">@l.StoredBalance.ToString("C")</td>
<td class="text-end">@l.LedgerBalance.ToString("C")</td>
<td class="text-end @(l.IsReconciled ? "" : "text-danger fw-semibold")">
@if (l.IsReconciled)
{
<i class="bi bi-check2 text-success"></i>
}
else
{
@l.Difference.ToString("C")
}
</td>
</tr>
}
</tbody>
</table>
</div>
</div>
</div>
@@ -31,9 +31,6 @@
<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
+3
View File
@@ -47,6 +47,9 @@
"BaseUrl": "https://columbiacoatings.com",
"ApiBasePath": "/wp-json/cca/v1"
},
"CatalogImport": {
"Token": ""
},
"SendGrid": {
"ApiKey": "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",
"FromEmail": "spouliot@scppowdercoating.com",
@@ -198,16 +198,16 @@
filled.push('Image');
}
// Vendor dropdown — match on the Manufacturer field first, catalog vendor name as fallback
// Vendor dropdown — match by name
const vendorSel = document.getElementById('field-vendor');
const mfrName = document.getElementById('field-manufacturer')?.value;
if (typeof window.matchInventoryVendor === 'function' &&
window.matchInventoryVendor(vendorSel, mfrName, item.vendorName)) {
filled.push('Vendor');
if (vendorSel && !vendorSel.value && item.vendorName) {
const needle = item.vendorName.toLowerCase();
const match = Array.from(vendorSel.options).find(o =>
o.text.toLowerCase().includes(needle) || needle.includes(o.text.toLowerCase().trim())
);
if (match) { vendorSel.value = match.value; filled.push('Vendor'); }
}
document.dispatchEvent(new CustomEvent('inventory:identity-changed'));
const discontinuedNote = item.isDiscontinued
? ' <span class="badge bg-warning text-dark ms-1">Discontinued</span>' : '';
@@ -1,263 +0,0 @@
/**
* Shared inventory duplicate UI.
*
* Owns the "already in inventory" modal and the manual Create-form preflight check.
* Label scanning calls window.inventoryDuplicateUi.show(...) so both entry paths use
* the same prompt and add-stock implementation.
*/
(function () {
'use strict';
const modalEl = document.getElementById('addStockModal');
const modal = modalEl ? bootstrap.Modal.getOrCreateInstance(modalEl) : null;
const itemNameEl = document.getElementById('add-stock-item-name');
const currentQtyEl = document.getElementById('add-stock-current-qty');
const uomEl = document.getElementById('add-stock-uom-label');
const qtyEl = document.getElementById('add-stock-qty');
const costEl = document.getElementById('add-stock-cost');
const notesEl = document.getElementById('add-stock-notes');
const modalStatusEl = document.getElementById('add-stock-status');
const addButton = document.getElementById('add-stock-confirm-btn');
const createSeparateButton = document.getElementById('add-stock-new-btn');
let activeData = null;
let activeOptions = {};
function show(data, options) {
if (!modal || !data?.existingInventoryId) return;
activeData = data;
activeOptions = options || {};
const uom = data.existingUnitOfMeasure || 'lbs';
itemNameEl.textContent = data.existingInventoryName || data.colorName || 'This product';
currentQtyEl.textContent = `${Number(data.existingQuantityOnHand || 0).toFixed(2)} ${uom}`;
uomEl.textContent = uom;
qtyEl.value = '';
costEl.value = Number(data.unitPrice || 0) > 0 ? data.unitPrice : '';
notesEl.value = '';
modalStatusEl.className = 'd-none';
modalStatusEl.textContent = '';
addButton.disabled = false;
addButton.innerHTML = '<i class="bi bi-plus-circle me-1"></i>Add Stock';
createSeparateButton.classList.toggle('d-none', data.isBlocking === true);
modal.show();
}
async function addStock() {
const quantity = Number(qtyEl.value);
if (!quantity || quantity <= 0) {
showModalStatus('danger', 'Enter a quantity greater than zero.');
return;
}
addButton.disabled = true;
addButton.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>Saving…';
try {
const params = new URLSearchParams({
inventoryItemId: activeData.existingInventoryId,
quantity: quantity.toString()
});
const unitCost = Number(costEl.value);
if (unitCost > 0) params.set('unitCost', unitCost.toString());
if (notesEl.value.trim()) params.set('notes', notesEl.value.trim());
const response = await fetch(`/Inventory/AddStock?${params}`, { method: 'POST' });
if (!response.ok) throw new Error(`Server error ${response.status}`);
const result = await response.json();
if (!result.success) {
showModalStatus('danger', result.errorMessage || 'Failed to add stock.');
return;
}
modal.hide();
if (typeof activeOptions.onAdded === 'function') {
activeOptions.onAdded(result, quantity, activeData);
} else {
showFormMessage(
'success',
`Added <strong>${quantity.toFixed(2)} ${escapeHtml(result.unitOfMeasure)}</strong> to ` +
`<strong>${escapeHtml(result.itemName)}</strong>. New stock: ` +
`${Number(result.newQuantityOnHand || 0).toFixed(2)} ${escapeHtml(result.unitOfMeasure)}.`
);
}
} catch (error) {
showModalStatus('danger', error.message);
} finally {
addButton.disabled = false;
addButton.innerHTML = '<i class="bi bi-plus-circle me-1"></i>Add Stock';
}
}
addButton?.addEventListener('click', addStock);
createSeparateButton?.addEventListener('click', function () {
modal?.hide();
if (typeof activeOptions.onCreateSeparate === 'function') {
activeOptions.onCreateSeparate(activeData);
}
});
window.inventoryDuplicateUi = { show };
const form = document.getElementById('inventory-create-form');
const statusEl = document.getElementById('inventory-duplicate-status');
const overrideEl = document.getElementById('duplicate-override-id');
if (!form || !statusEl || !overrideEl) return;
const fields = {
sku: document.getElementById('field-sku'),
categoryId: document.getElementById('field-category'),
manufacturer: document.getElementById('field-manufacturer'),
manufacturerPartNumber: document.getElementById('field-partnumber'),
colorName: document.getElementById('field-colorname')
};
let timer = null;
let requestVersion = 0;
let latestDuplicate = null;
let acknowledgedSignature = null;
function signature() {
return Object.values(fields)
.map(field => field?.value?.trim().toUpperCase() || '')
.join('|');
}
function scheduleCheck() {
overrideEl.value = '';
acknowledgedSignature = null;
clearTimeout(timer);
timer = setTimeout(() => checkDuplicate(false), 400);
}
Object.values(fields).forEach(field => {
field?.addEventListener('input', scheduleCheck);
field?.addEventListener('change', scheduleCheck);
field?.addEventListener('blur', () => checkDuplicate(false));
});
document.addEventListener('inventory:identity-changed', scheduleCheck);
async function checkDuplicate(showChecking) {
const version = ++requestVersion;
const params = new URLSearchParams();
Object.entries(fields).forEach(([key, field]) => {
if (field?.value?.trim()) params.set(key, field.value.trim());
});
if (!params.has('sku') &&
!(params.has('manufacturer') &&
(params.has('manufacturerPartNumber') || params.has('colorName')))) {
latestDuplicate = null;
hideStatus();
return null;
}
if (showChecking) showFormMessage('info', 'Checking existing inventory…');
try {
const response = await fetch(`/Inventory/CheckDuplicate?${params}`);
if (!response.ok) throw new Error(`Server error ${response.status}`);
const data = await response.json();
if (version !== requestVersion) return latestDuplicate;
latestDuplicate = data.hasDuplicate ? data : null;
if (!latestDuplicate) {
hideStatus();
return null;
}
renderDuplicate(latestDuplicate);
return latestDuplicate;
} catch {
if (showChecking) {
showFormMessage(
'warning',
'Inventory could not be checked right now. The item will be checked again when saved.'
);
}
return latestDuplicate;
}
}
function renderDuplicate(data) {
const quantity = Number(data.existingQuantityOnHand || 0).toFixed(2);
const uom = escapeHtml(data.existingUnitOfMeasure || 'lbs');
const viewLink = `/Inventory/Details/${data.existingInventoryId}`;
const overrideButton = data.isBlocking
? ''
: '<button type="button" class="btn btn-sm btn-outline-secondary duplicate-create-separate">Create separate entry</button>';
statusEl.className = 'alert alert-warning mb-3';
statusEl.innerHTML = `
<div class="d-flex gap-2 align-items-start">
<i class="bi bi-exclamation-triangle-fill mt-1"></i>
<div class="flex-grow-1">
<div class="fw-semibold">Already in inventory</div>
<div class="small">${escapeHtml(data.message)}</div>
<div class="small text-muted mt-1">Current stock: ${quantity} ${uom}</div>
<div class="d-flex flex-wrap gap-2 mt-2">
<a class="btn btn-sm btn-outline-secondary" href="${viewLink}">View existing item</a>
<button type="button" class="btn btn-sm btn-primary duplicate-add-stock">Add stock</button>
${overrideButton}
</div>
</div>
</div>`;
statusEl.querySelector('.duplicate-add-stock')?.addEventListener('click', () => show(data));
statusEl.querySelector('.duplicate-create-separate')?.addEventListener('click', () => {
overrideEl.value = data.existingInventoryId;
acknowledgedSignature = signature();
statusEl.className = 'alert alert-warning mb-3';
statusEl.innerHTML =
`<strong>Separate entry confirmed.</strong> ${escapeHtml(data.message)} ` +
`<a class="alert-link" href="${viewLink}">View existing item</a>`;
});
}
form.addEventListener('submit', async function (event) {
if (form.dataset.duplicateValidated === 'true') {
form.dataset.duplicateValidated = '';
return;
}
event.preventDefault();
const duplicate = await checkDuplicate(true);
const isAcknowledged = duplicate &&
!duplicate.isBlocking &&
Number(overrideEl.value) === Number(duplicate.existingInventoryId) &&
acknowledgedSignature === signature();
if (!duplicate || isAcknowledged) {
form.dataset.duplicateValidated = 'true';
form.requestSubmit(event.submitter);
return;
}
statusEl.scrollIntoView({ behavior: 'smooth', block: 'center' });
statusEl.querySelector('button, a')?.focus({ preventScroll: true });
});
function showModalStatus(type, message) {
modalStatusEl.className = `alert alert-${type} py-2 small`;
modalStatusEl.textContent = message;
}
function showFormMessage(type, message) {
statusEl.className = `alert alert-${type} mb-3`;
statusEl.innerHTML = message;
}
function hideStatus() {
statusEl.className = 'd-none mb-3';
statusEl.innerHTML = '';
}
function escapeHtml(value) {
return String(value ?? '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
})();
@@ -46,14 +46,33 @@
const processingEl = document.getElementById('scan-processing');
const processingMsgEl= document.getElementById('scan-processing-msg');
let _lastScanData = null;
// Add-stock modal elements
const addStockModalEl = document.getElementById('addStockModal');
const bsAddStockModal = addStockModalEl ? new bootstrap.Modal(addStockModalEl) : null;
const addStockItemName = document.getElementById('add-stock-item-name');
const addStockCurrentQty= document.getElementById('add-stock-current-qty');
const addStockUomLabel = document.getElementById('add-stock-uom-label');
const addStockQtyInput = document.getElementById('add-stock-qty');
const addStockCostInput = document.getElementById('add-stock-cost');
const addStockNotesInput= document.getElementById('add-stock-notes');
const addStockStatusEl = document.getElementById('add-stock-status');
const addStockConfirmBtn= document.getElementById('add-stock-confirm-btn');
let _addStockItemId = null;
let _lastScanData = null;
if (!modalEl || !videoEl || !canvasEl) return;
scanBtn.addEventListener('click', openScanner);
modalEl.addEventListener('hide.bs.modal', onModalClose);
if (shutterBtn) shutterBtn.addEventListener('click', captureFrame);
if (addStockConfirmBtn) addStockConfirmBtn.addEventListener('click', submitAddStock);
// "Create new entry instead" hides the add-stock modal and pre-fills the create form
const addStockNewBtn = document.getElementById('add-stock-new-btn');
if (addStockNewBtn) addStockNewBtn.addEventListener('click', () => {
bsAddStockModal?.hide();
if (_lastScanData) fillFromScan(_lastScanData, /* skipDuplicatePrompt */ true);
});
window.addEventListener('beforeunload', releaseCamera);
// Pre-warm camera if browser has already granted permission (no prompt risk)
@@ -307,10 +326,9 @@
if (data.existingInventoryId) {
// Product already in inventory — show inline add-stock prompt
_lastScanData = data;
window.inventoryDuplicateUi?.show(data, {
onCreateSeparate: () => fillFromScan(_lastScanData, true)
});
_lastScanData = data;
_addStockItemId = data.existingInventoryId;
openAddStockModal(data);
} else {
fillFromScan(data);
}
@@ -321,6 +339,79 @@
}
}
// ── Add-stock modal ───────────────────────────────────────────────────
function openAddStockModal(data) {
if (!bsAddStockModal) { fillFromScan(data); return; }
const uom = data.existingUnitOfMeasure || 'lbs';
if (addStockItemName) addStockItemName.textContent = data.existingInventoryName || data.colorName || 'This product';
if (addStockCurrentQty) addStockCurrentQty.textContent = `${(data.existingQuantityOnHand ?? 0).toFixed(2)} ${uom}`;
if (addStockUomLabel) addStockUomLabel.textContent = uom;
if (addStockQtyInput) addStockQtyInput.value = '';
if (addStockCostInput) addStockCostInput.value = data.unitPrice > 0 ? data.unitPrice : '';
if (addStockNotesInput) addStockNotesInput.value = '';
if (addStockStatusEl) { addStockStatusEl.className = 'd-none'; addStockStatusEl.textContent = ''; }
if (addStockConfirmBtn) addStockConfirmBtn.disabled = false;
bsAddStockModal.show();
}
async function submitAddStock() {
const qty = parseFloat(addStockQtyInput?.value);
if (!qty || qty <= 0) {
showAddStockStatus('danger', 'Please enter a quantity greater than zero.');
return;
}
addStockConfirmBtn.disabled = true;
addStockConfirmBtn.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>Saving…';
try {
const params = new URLSearchParams({
inventoryItemId: _addStockItemId,
quantity: qty,
});
const cost = parseFloat(addStockCostInput?.value);
if (cost > 0) params.append('unitCost', cost);
const notes = addStockNotesInput?.value?.trim();
if (notes) params.append('notes', notes);
const resp = await fetch('/Inventory/AddStock?' + params.toString(), { method: 'POST' });
if (!resp.ok) throw new Error(`Server error ${resp.status}`);
const data = await resp.json();
if (!data.success) {
showAddStockStatus('danger', data.errorMessage || 'Failed to add stock.');
addStockConfirmBtn.disabled = false;
addStockConfirmBtn.innerHTML = '<i class="bi bi-plus-circle me-1"></i>Add Stock';
return;
}
// Success — close modal and show confirmation on the form
bsAddStockModal.hide();
showFormStatus('success',
`<i class="bi bi-check-circle-fill me-1"></i>` +
`Added <strong>${qty.toFixed(2)} ${data.unitOfMeasure}</strong> to <strong>${data.itemName}</strong>. ` +
`New stock: ${(data.newQuantityOnHand ?? 0).toFixed(2)} ${data.unitOfMeasure}. ` +
`<a href="/Inventory/Details/${_addStockItemId}" class="alert-link">View item</a>`
);
} catch (err) {
showAddStockStatus('danger', 'Error: ' + err.message);
addStockConfirmBtn.disabled = false;
addStockConfirmBtn.innerHTML = '<i class="bi bi-plus-circle me-1"></i>Add Stock';
}
}
function showAddStockStatus(type, msg) {
if (!addStockStatusEl) return;
addStockStatusEl.className = `alert alert-${type} py-2 small`;
addStockStatusEl.textContent = msg;
}
// ── Fill the inventory form from scan result ───────────────────────────
function fillFromScan(data, skipDuplicatePrompt = false) {
const filled = [];
@@ -401,10 +492,12 @@
}
const vendorSel = document.getElementById('field-vendor');
const mfrName = document.getElementById('field-manufacturer')?.value || data.manufacturer;
if (typeof window.matchInventoryVendor === 'function' &&
window.matchInventoryVendor(vendorSel, mfrName, data.vendorName)) {
filled.push('Vendor');
if (vendorSel && !vendorSel.value && data.vendorName) {
const needle = data.vendorName.toLowerCase();
const match = Array.from(vendorSel.options).find(o =>
o.text.toLowerCase().includes(needle) || needle.includes(o.text.toLowerCase().trim())
);
if (match) { vendorSel.value = match.value; filled.push('Vendor'); }
}
const catalogNote = data.wasInCatalog
@@ -413,8 +506,6 @@
? ' <span class="badge bg-success ms-1">Added to platform catalog</span>'
: '';
document.dispatchEvent(new CustomEvent('inventory:identity-changed'));
if (data.existingInventoryId && !skipDuplicatePrompt) {
// Duplicate handled by add-stock modal — don't show a banner here
} else if (data.existingInventoryId && skipDuplicatePrompt) {
@@ -1,67 +0,0 @@
/**
* Shared vendor-dropdown auto-select for the Inventory Create/Edit forms.
*
* Why this exists: catalog lookup, AI lookup, label scan, and manual manufacturer entry
* all need to pick the right Vendor option, and they used to each carry their own copy of
* the matching logic. They disagreed on WHAT to match on the AI path keyed off the
* price-derived `vendorName` (which is null unless a price was scraped), so the vendor
* only got selected "sometimes". This centralizes the rule:
*
* For ~95% of powders the manufacturer IS the vendor (Prismatic, Columbia,
* All Powder Paints, Tiger, Powder Buy The Pound). So match on the Manufacturer
* field first it's almost always populated and only fall back to the
* AI/catalog-supplied vendor name when the manufacturer is blank.
*
* Brands sold by more than one distributor (e.g. PPG, KP Pigments) are intentionally
* skipped so the user picks the vendor manually rather than getting a wrong guess.
*/
(function () {
'use strict';
// Brands carried by multiple distributors — never auto-pick a vendor for these.
// Lowercase; matched as a substring against the manufacturer name. Extend as needed.
const AMBIGUOUS_BRANDS = ['ppg', 'kp pigments', 'kp pigment'];
function normalize(s) {
return (s || '').toLowerCase().trim();
}
function isAmbiguousBrand(name) {
const n = normalize(name);
return n.length > 0 && AMBIGUOUS_BRANDS.some(b => n.includes(b));
}
/**
* Selects the vendor dropdown option that best matches a manufacturer/vendor name.
*
* @param {HTMLSelectElement} vendorSelect the #field-vendor element
* @param {string} manufacturerName primary name to match on (the Manufacturer field)
* @param {string} fallbackVendorName AI/catalog vendor name, used only if manufacturer is blank
* @param {{force?: boolean}} [opts] force=true overrides an existing selection (bad-match retry)
* @returns {boolean} true if a vendor option was selected.
*/
window.matchInventoryVendor = function (vendorSelect, manufacturerName, fallbackVendorName, opts) {
opts = opts || {};
if (!vendorSelect) return false;
// Don't clobber a choice the user (or a prior fill) already made, unless forcing a re-fill.
if (vendorSelect.value && !opts.force) return false;
// Manufacturer drives the match; the price-derived vendor name is only a fallback.
const name = normalize(manufacturerName) || normalize(fallbackVendorName);
if (!name) return false;
// Brands sold by multiple distributors stay manual — don't guess.
if (isAmbiguousBrand(name)) return false;
const match = Array.from(vendorSelect.options).find(function (o) {
const t = normalize(o.text);
// Skip the placeholder and the "Add new vendor" sentinel; require a real name to
// avoid spurious substring hits (e.g. empty option text matches everything).
if (!o.value || o.value === '__new__' || t.length < 3) return false;
return t.includes(name) || name.includes(t);
});
if (match) { vendorSelect.value = match.value; return true; }
return false;
};
})();
@@ -88,17 +88,7 @@
tips: ['Download the CSV template to see the expected columns',
'Customers must exist before importing — matched by CustomerEmail then Customer name',
'Existing invoices matched by InvoiceNumber are updated; new ones are created',
'Line items import separately — run the Invoice Line Items import after this one'] },
{ key: 'csv-invoiceitems',
label: 'Invoice Line Items', icon: 'bi-list-ul', color: '#0e7490',
desc: 'Invoice line items with revenue account attribution',
dir: ['import'], fmt: ['csv'],
endpoint: '/Tools/CsvImportInvoiceItems', accept: '.csv',
template: '/Tools/DownloadInvoiceItemTemplate',
tips: ['Import invoices first — each line is matched to its parent by InvoiceNumber',
'RevenueAccountNumber is optional and matched against your Chart of Accounts',
'Re-running is safe — duplicate lines (same description + total + order) are skipped'] },
'Line items are not part of the CSV — this imports invoice headers and totals only'] },
{ key: 'csv-appointments',
label: 'Appointments', icon: 'bi-calendar-check', color: '#2563eb',
@@ -167,53 +157,6 @@
'Valid PaymentMethod values: Cash, Check, CreditDebitCard, BankTransferACH, DigitalPayment',
'Invoice AmountPaid and status are updated automatically after each payment'] },
{ key: 'csv-bills',
label: 'Bills (AP)', icon: 'bi-file-earmark-ruled', color: '#b45309',
desc: 'Vendor bill headers — vendor by name, AP account by number',
dir: ['import'], fmt: ['csv'],
endpoint: '/Tools/CsvImportBills', accept: '.csv',
tips: ['Import Chart of Accounts and Vendors first',
'VendorName matches a vendor; APAccountNumber matches your Chart of Accounts',
'Existing bills matched by BillNumber are skipped',
'Import bill line items separately after this'] },
{ key: 'csv-billlineitems',
label: 'Bill Line Items', icon: 'bi-list-ul', color: '#b45309',
desc: 'Bill line items with expense account attribution',
dir: ['import'], fmt: ['csv'],
endpoint: '/Tools/CsvImportBillLineItems', accept: '.csv',
tips: ['Import bills first — lines are matched to their parent by BillNumber',
'AccountNumber (expense/asset) and JobNumber are optional',
'Re-running is safe — duplicate lines are skipped'] },
{ key: 'csv-deposits',
label: 'Customer Deposits', icon: 'bi-piggy-bank', color: '#059669',
desc: 'Deposits with customer, bank account, and applied invoice',
dir: ['import'], fmt: ['csv'],
endpoint: '/Tools/CsvImportDeposits', accept: '.csv',
tips: ['Import Customers and Chart of Accounts first',
'CustomerName matches a customer; DepositAccountNumber matches your Chart of Accounts',
'AppliedToInvoiceNumber is optional — links the deposit to an invoice',
'Existing deposits matched by ReceiptNumber are skipped'] },
{ key: 'csv-journalentries',
label: 'Journal Entries', icon: 'bi-journal-bookmark', color: '#374151',
desc: 'Journal entry headers — import lines separately after',
dir: ['import'], fmt: ['csv'],
endpoint: '/Tools/CsvImportJournalEntries', accept: '.csv',
tips: ['Existing entries matched by EntryNumber are skipped',
'Valid Status values: Draft, Posted, Reversed',
'Import the entry lines separately after this'] },
{ key: 'csv-journalentrylines',
label: 'Journal Entry Lines', icon: 'bi-list-columns', color: '#374151',
desc: 'Debit/credit lines with account attribution',
dir: ['import'], fmt: ['csv'],
endpoint: '/Tools/CsvImportJournalEntryLines', accept: '.csv',
tips: ['Import journal entries and Chart of Accounts first',
'Lines are matched to their entry by EntryNumber; AccountNumber is required',
'Each line is a debit or a credit — entries should balance'] },
{ key: 'csv-purchaseorders',
label: 'Purchase Orders', icon: 'bi-cart', color: '#6b7280',
desc: 'Purchase order headers with vendor, status, and totals',
@@ -261,41 +204,11 @@
desc: 'Invoice headers, amounts, status, and customer info',
dir: ['export'], fmt: ['csv'], exportUrl: '/Tools/ExportInvoicesCsv' },
{ key: 'exp-invoiceitems',
label: 'Invoice Line Items', icon: 'bi-list-ul', color: '#0e7490',
desc: 'Line items with revenue account, keyed by invoice number',
dir: ['export'], fmt: ['csv'], exportUrl: '/Tools/ExportInvoiceItemsCsv' },
{ key: 'exp-payments',
label: 'Payments', icon: 'bi-cash-coin', color: '#059669',
desc: 'Invoice payment records with method and reference',
dir: ['export'], fmt: ['csv'], exportUrl: '/Tools/ExportPaymentsCsv' },
{ key: 'exp-bills',
label: 'Bills (AP)', icon: 'bi-file-earmark-ruled', color: '#b45309',
desc: 'Vendor bill headers with vendor and AP account',
dir: ['export'], fmt: ['csv'], exportUrl: '/Tools/ExportBillsCsv' },
{ key: 'exp-billlineitems',
label: 'Bill Line Items', icon: 'bi-list-ul', color: '#b45309',
desc: 'Bill line items with expense account, keyed by bill number',
dir: ['export'], fmt: ['csv'], exportUrl: '/Tools/ExportBillLineItemsCsv' },
{ key: 'exp-deposits',
label: 'Customer Deposits', icon: 'bi-piggy-bank', color: '#059669',
desc: 'Deposits with customer, bank account, and applied invoice',
dir: ['export'], fmt: ['csv'], exportUrl: '/Tools/ExportDepositsCsv' },
{ key: 'exp-journalentries',
label: 'Journal Entries', icon: 'bi-journal-bookmark', color: '#374151',
desc: 'Journal entry headers with reference and status',
dir: ['export'], fmt: ['csv'], exportUrl: '/Tools/ExportJournalEntriesCsv' },
{ key: 'exp-journalentrylines',
label: 'Journal Entry Lines', icon: 'bi-list-columns', color: '#374151',
desc: 'Debit/credit lines with account, keyed by entry number',
dir: ['export'], fmt: ['csv'], exportUrl: '/Tools/ExportJournalEntryLinesCsv' },
{ key: 'exp-appointments',
label: 'Appointments', icon: 'bi-calendar-check', color: '#d97706',
desc: 'Customer, type, status, and scheduling details',

Some files were not shown because too many files have changed in this diff Show More