Compare commits

..

49 Commits

Author SHA1 Message Date
spouliot c73dc08b92 Add trial-balance (GL health) indicator to Chart of Accounts
Surfaces the audit #2 check in the UI: a badge at the top of the Chart of
Accounts page shows "Trial balance: Balanced" or "off by $X (excess
debits/credits)". Computed in AccountsController.Index from already-loaded
accounts: debit-normal (Asset/COGS/Expense) minus credit-normal
(Liability/Equity/Revenue) CurrentBalance, which nets to ~0 for balanced
books. Helps companies spot one-sided postings / opening-balance gaps.

Help KB + Settings article updated.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 18:15:36 -04:00
spouliot 06a5070a92 Add idempotent SQL script for AddCompanyDefaultGlAccounts migration
Migrations are not auto-applied on startup (Program.cs:856 is commented),
so dev/prod DBs need this applied manually. Idempotent (guarded by
__EFMigrationsHistory checks) — safe to run regardless of current state.
Adds CompanyPreferences.Default{Revenue,Cogs,Inventory}AccountId + indexes + FKs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 18:09:40 -04:00
spouliot 27cf4532cf Reconcile account type/sub-type in QBO + CSV imports
Closes the remaining type/sub-type-consistency vector (create/edit + IIF
were already covered). The sign convention keys off sub-type, so a
mismatched pair posts with the wrong debit/credit sign.

- New AccountClassification.DefaultSubTypeForType(type).
- QBO import: QBO Type is reliable but DetailType frequently isn't
  mappable and fell back to AccountSubType.Other (an expense-range sub-type)
  — so an unmapped Liability/Equity/Revenue account would have posted
  debit-normal. Now reconciles the sub-type to the type when they disagree.
- CSV import: type and sub-type came from independent columns; now derives
  the type from the sub-type (sub-type authoritative, matching create/edit).
- IIF import already returns consistent (type, sub-type) tuples — unchanged.

Build clean; 293 unit tests pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 18:06:06 -04:00
spouliot 4df85d75db Gate Tools and OvenScheduler controllers (authorization audit #3)
Both were class-level [Authorize] only, so any authenticated user
(including ReadOnly/Employee/ShopFloor) could reach state-changing actions:

- ToolsController (32 POSTs: bulk CSV + QuickBooks import/export of
  customers, invoices, financials, inventory, etc.) -> CanManageInvoices.
  Closes a data-egress + bulk-import gap; low-privilege roles can no longer
  export or import company data.
- OvenSchedulerController (9 POSTs: create/add/move/remove/start/complete/
  delete batch) -> CanManageJobs, matching the shop-ops domain.

Audit #3 otherwise clean: ~75/80 controllers correctly gated, platform
surface consistently SuperAdminOnly, anonymous controllers intentional
(webhooks/public flows), PasskeyController correctly per-action gated, and
this session's earlier changes (SaveDefaultAccounts -> CompanyAdminOnly,
QB sign-fix -> SuperAdminOnly) verified correct.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 17:57:10 -04:00
spouliot f9039fc735 Record GL trial-balance integrity check (audit #2)
Empirical per-company trial-balance net on stored CurrentBalance. Both
tenants imbalanced, but from pre-existing data, not this session:
- Demo: $89.5k opening-balance-without-equity (demo artifact) + $3,153.63 postings.
- SCP: $3,079.52, all postings. Forensics: AR reconciles (invoices−payments),
  but Revenue has $0 GL movement (24 header-only invoices, 0 line items → no
  per-item revenue credit) and payment-side bank debit never posted. One-sided
  postings from imported/header-only docs + null offset accounts skipped by
  AccountBalanceService — same class as O2/O6/O7/O8.

Conclusion: this session's changes did not introduce the imbalance and in
fact prevent the bug class going forward. Remediation options documented
(not auto-applied — SCP is live data).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 17:48:48 -04:00
spouliot f804906481 Finish FindAsync tenant sweep: CompanySettings, dashboards, lookups
Completes the multi-tenant defense-in-depth FindAsync/Count/Any pass.

- CompanySettings (~21): in-use Count checks, dup-code Any checks, and the
  Quote-status "single approved/converted" Any checks now filter by
  CompanyId. (Were scoped for normal users via the global filter; this
  hardens them against raw platform-admin sessions.)
- ReportsController.Analytics: powder-usage transactions scoped.
- Dashboard pill counts (Invoices, Jobs) + onboarding status-history check.
- JobsPriority / Jobs ShopDisplay+ShopMobile: today's priorities,
  maintenance, and status-lookup queries scoped.
- OvenScheduler: scheduled-coat set and CompanyOperatingCosts lookup
  (c => true) scoped — the latter could return another tenant's defaults
  under a raw platform-admin session.

Confirmed safe / left as-is: parent-FK child queries, by-PK fetches,
platform tables (PowderCatalog, SubscriptionPlanConfig), SuperAdmin-only
controllers (AuditLog/UserActivity/StripeEvents/SubscriptionManagement),
already-filtered IQueryables, and intentional IgnoreQueryFilters number
generators. Build clean; 293 unit tests pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 17:30:21 -04:00
spouliot c0d3a30176 Add explicit CompanyId to tenant-scoped FindAsync queries (partial sweep)
Multi-tenant defense-in-depth sweep, FindAsync/FirstOrDefaultAsync vector.
Adds explicit CompanyId predicates to list/index/validation queries that
previously relied only on the global tenant filter (exposure: raw
platform-admin sessions where the filter is bypassed).

Done this pass:
- Financial: Budgets, CreditMemos, FixedAssets, GiftCertificates,
  TaxRates, PricingTiers, VendorCredits, Accounts (year-end close),
  Invoices (tax-rate default, merchandise).
- Operational: Inventory (bin/sample-panels/vendors/usage-edit),
  OvenScheduler (ovens/batches/queue), Customers (pricing tiers),
  InAppNotifications (mark-all-read), CatalogItems (by-category /
  merchandise / price-check lists).
- AI: AiQuickQuote and Quotes (powder cost, predictions, walk-in
  customer, benchmark), Reports (budgets, 1099 vendors).

Child-by-parent-FK and by-PK queries were left as-is (already scoped via
the verified parent). Builds clean; 293 unit tests pass.

REMAINING (next session): ReportsController.Analytics powder-usage query
(line ~593) and the ~20 CompanySettings delete-protection Count/Any +
dup-code checks.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 16:53:29 -04:00
spouliot 7c0357b4c5 Add explicit CompanyId filter to tenant-scoped GetAllAsync calls
Multi-tenant defense-in-depth sweep (GetAllAsync vector). These queries
relied solely on the global tenant query filter, so a SuperAdmin session
(filter bypassed) would have merged/exported every tenant's data. Now each
filters explicitly by CompanyId:

- ToolsController: all 11 CSV export methods (Customers, Quotes, Jobs,
  Invoices, Payments, Purchase Orders x2, Inventory, Maintenance,
  Appointments, Expenses) — converted GetAllAsync -> FindAsync(CompanyId).
  Highest impact: these are bulk data exports.
- KioskController.Intakes: kiosk sessions (+ linked customer/job).
- BillsController recurring-bill detection: companyId was computed but
  never applied to the query.

Verified safe (no change): the ignoreQueryFilters cases (SKU-sequence
generators read only SKU strings; the SuperAdmin category repair),
OvenScheduler (indirectly scoped by the company's job-item ids), and the
platform/SuperAdmin controllers (cross-company by design).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 10:52:45 -04:00
spouliot 774f916dae Guard money-account selections; derive account type from sub-type
Item 1 — server-side guard (defense in depth) on payment-source / deposit
/ reconcilable account selections. New AccountGuard.IsValidMoneyAccountAsync
checks the submitted account is active, company-owned, and an Asset or
Liability before any GL posting, at: bill RecordPayment, bill Create
(payNow), bill EditPayment, BankReconciliation.Create, and deposit Record.
The dropdowns already constrain normal users; this rejects tampered/stale
POSTs. Per the "trust the operator" decision it still allows A/R etc.
(any Asset/Liability) — it only blocks non-money types.

Item 2 — account AccountType is now derived from the chosen AccountSubType
on create/edit via the new AccountClassification.TypeForSubType (single
source of truth, also used by the Create pre-select). The two can no longer
disagree, so the sub-type-based debit/credit sign convention is always
consistent with the account's type. A read-only sweep of the dev DB found
0 existing mismatches, so no repair tool was built.

Audit doc updated: both backlog items marked resolved.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 10:38:44 -04:00
spouliot 74d529f7d2 Accounting audit fixes: revenue default IsActive + deposit account guard
Audit of this session's accounting changes (sub-type→type dropdowns,
deposit account picker, default GL accounts) found no ledger-drift bugs.
Two fixes applied:

- Default revenue account now requires IsActive (mirrors the 4000
  fallback), so a deactivated default isn't silently posted to.
- DepositsController.Record blocks recording when the 2300 Customer
  Deposits liability exists but no deposit/bank account resolves — that
  would post a one-sided entry. When 2300 doesn't exist (no accounting),
  nothing posts, so the deposit is still allowed.

ACCOUNTING_AUDIT.md updated: O9 footgun surface widened by the default-
accounts feature (now mitigated/documented), plus the 2026-06-20 review
notes and the resolved deposit-imbalance item.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 10:23:47 -04:00
spouliot 58a0010ae8 Label blank account option "(None)" when no company default is set
The item account dropdowns showed "(Default … account)" on the blank
option even when the company hadn't configured a default — implying a
fallback that didn't exist. Now the blank option reads "(None)" unless a
matching company default is configured, in which case it keeps the
"(Default …)" label (the dropdown pre-selects the real account anyway).
"Has default" flags are computed in the shared dropdown-population helpers
so every render path (Create/Edit GET and invalid-POST) gets them.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 10:10:04 -04:00
spouliot ee86d7aaf6 Add company default GL accounts; move QB sign-fix to platform
Default accounts: companies can now set a default Revenue, COGS, and
Inventory account (Chart of Accounts -> "Default Accounts" card). Stored
on CompanyPreferences (3 nullable FKs + migration). Used as the fallback
when an item or invoice line leaves its account blank:
 - Invoice lines fall back to the default Revenue account, then to 4000.
 - New inventory/catalog items are pre-filled with the COGS/Inventory
   defaults, so the value is stored on the item and both live posting and
   the balance-recompute path stay consistent.
Blank defaults = unchanged behavior, so nothing changes until a company
opts in. Setting both COGS + Inventory enables perpetual-inventory COGS
posting (warned in the UI and help docs). Help KB + Settings article
updated.

Also moves the "Fix QB Import Signs" tool off the company Chart of
Accounts page (was CompanyAdmin-visible) to the SuperAdmin-only platform
Company Details page, operating on the target company.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 10:03:11 -04:00
spouliot 687aedf7a4 Fix account dropdowns: vendor auto-select + sub-type filtering
Inventory vendor auto-select: match the dropdown off the Manufacturer
field (almost always populated and equal to the vendor for the shop's
distributors) instead of the AI's price-conditional vendorName, which was
only returned when a price was scraped. Centralizes the logic in a shared
inventory-vendor-match.js used by catalog lookup, AI lookup, label scan,
and manual entry; skips brands sold by multiple distributors (PPG, KP
Pigments) so those stay manual.

Account dropdowns filtered by sub-type now filter by parent AccountType,
so accounts a company classifies under a non-standard sub-type still
appear: Inventory account (Asset), AP account (Liability), pay-from/bank
and Bank Reconciliation pickers (Asset + Liability).

Deposit account is now a user-selectable dropdown on the Job and Quote
deposit modals (Asset + Liability accounts) instead of a silent auto-pick
of the first Checking/Cash account; falls back to the old behavior when
left blank, and validates the chosen account belongs to the company.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 09:28:22 -04:00
spouliot 8b9a3dff41 Add accounting deploy & verification checklist
Repeatable post-deploy (and periodic) check that proves the books are consistent
against real data: per company, Trial Balance debits==credits, Balance Reconciliation
shows no drift, then Recalculate Balances and re-check. Includes the read-only
pre-deploy migration preview, the two pending migrations in order, account spot-checks
for the audit-touched accounts, and the inventory/sales-tax policy reminders.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 21:42:32 -04:00
spouliot 57ec3ed127 Record O9 decision: expense materials at purchase (periodic inventory)
Owner decision: this is a service business that uses materials to deliver a service
rather than selling inventory, so powder/consumables are expensed at purchase (bill
to a COGS/expense account) and inventory is not capitalized on the Balance Sheet.

No code change required — the Balance Sheet already behaves this way, and the
perpetual consumption-COGS path (O6) is opt-in via item account mappings (set only
via CSV import) and stays dormant under this policy. Documents the double-count
footgun (do not both expense at purchase and map item COGS/Inventory accounts) and
locks the periodic choice into the ledger-refactor plan's Phase 5.

All accounting audit findings O1-O9 now resolved.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 21:37:43 -04:00
spouliot 012f4d9a3e Document the JournalEntry single-source ledger refactor plan
Captures the phased plan to make JournalEntry lines the single source of truth for
all GL balances/reports, retiring the parallel re-derivation in LedgerService and
FinancialReportService. Resolves the O2/O6/O7/O8 bug class structurally and folds in
O9 (inventory capitalization) at Phase 5. Proposed only — not started.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 21:34:12 -04:00
spouliot 7834d67432 Recompute inventory-consumption COGS and fix written-off AR (audit O6, O8)
O6: inventory consumed on jobs posts DR COGS / CR Inventory, but neither recompute
engine reflected it — so reports understated COGS / overstated inventory and a
"Recalculate Balances" wiped the effect. The COGS posting fires only for JobUsage
and Waste transaction types, which are created only at the two COGS-posting sites,
so the consumption is exactly identifiable from InventoryTransaction:
  - both posting sites now record consumption at the effective (weighted-average)
    unit cost so TotalCost equals the COGS posted (the recompute reads TotalCost)
  - LedgerService: new section (dated rows + prior balance) crediting Inventory /
    debiting COGS from JobUsage/Waste rows on items with both accounts mapped
  - FinancialReportService: Trial Balance + accrual P&L include consumption COGS
This reads existing transactions, so historical data is covered with no backfill.
The Balance Sheet inventory line is intentionally left alone — it does not track
inventory purchases either (periodic), so relieving it for consumption alone would
unbalance it; tracked as O9 (inventory capitalization policy).

O8: the write-off already creates a balanced posted JournalEntry (both engines read
it via their JE-line sections). The real defect was 4 "Status != WrittenOff" filters
in FinancialReportService that excluded pre-write-off payments from AR credits and
bank debits — leaving the paid portion dangling as open AR and understating the bank.
Removed those filters; AR now nets to zero for written-off invoices and the trial
balance balances. No backfill needed.

Adds a LedgerService regression test for inventory consumption. Build clean; 293
unit tests pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 21:27:20 -04:00
spouliot 91ed19c2b1 Credit AR for gift-certificate redemptions in balance recompute (audit O7)
ApplyGiftCertificate posts DR 2500 Gift Certificate Liability / CR AR, but the AR
recompute only subtracted payments and credit-memo applications — so the redemption's
2500 debit was recomputed while its AR credit was not, leaving the Trial Balance out
of balance by the total gift-certificate amount redeemed and overstating AR on the
Balance Sheet.

Subtract GC redemptions from AR in both recompute engines:
  - FinancialReportService: Balance Sheet (gcRedeemedBs) and Trial Balance (gcRedeemedTb)
  - LedgerService: AR section (dated rows) and ComputePriorBalanceAsync (prior balance)

AR Aging was already correct (uses BalanceDue, which includes GiftCertificateRedeemed).
Adds a LedgerService regression test. Build clean; 292 unit tests pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 21:08:16 -04:00
spouliot 08a5cd39d4 Scope all controller account lookups by CompanyId (defense-in-depth sweep)
Completes the read-path defense-in-depth pass flagged in the accounting audit:
every Accounts lookup in a controller now carries an explicit CompanyId predicate,
matching the standing rule in CLAUDE.md ("every FindAsync/GetAllAsync must include
an explicit CompanyId"). ~19 lookups across 12 controllers:

  - Tier 1 (write-path): AccountsController duplicate account-number check (Create/Edit)
  - Tier 2 (dropdowns/lists): Accounts (Index/year-end/parent), BankReconciliations,
    Bills (bank list + receipt scan + suggest), Budgets, CatalogItems, Expenses,
    FixedAssets, Inventory, JournalEntries chart dropdown, Vendors
  - Tier 3 (accountIds.Contains display maps): JournalEntries/Reports/VendorCredits
    detail views, scoped via the in-scope entity's CompanyId for uniformity

companyId source per controller: _tenantContext where available, else the in-scope
entity's CompanyId, else the current user. Build clean; 291 unit tests pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 20:49:16 -04:00
spouliot df194bd64b Seed and self-heal Gift Certificate Liability account 2500 (audit O5)
Account 2500 is resolved by number as the GC liability (GiftCertificatesController),
but the per-tenant seeder never created it — so tenants onboarded after the
AccountingGapsPhase2 migration had no GC liability account and gift-certificate GL
postings silently no-op'd. The default-company seeder also created 2500 as
"Long-Term Loan", mislabeling that company's GC obligations.

  - SeedDataService.Accounts: seed 2500 "Gift Certificate Liability" (IsSystem)
  - SeedData: seed 2500 as GC liability; move long-term loan to 2900
  - EnsureSystemAccountsAsync: self-heal — rename a 2500 still named "Long-Term Loan"
    (preserving user renames) and ensure a 2500 exists
  - migration FixGiftCertificateLiabilityAccount: move long-term loan to 2900 where a
    2500="Long-Term Loan" exists without a 2900, relabel the mislabeled 2500, and
    safety-net insert a 2500 for any company lacking one

Non-destructive: no account Id/number/balance is changed (same pattern as O1).
Verified on dev: existing GC-liability rows preserved, no spurious accounts added.
All audit findings O1-O5 resolved. Build clean; 291 unit tests pass; migration applied.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 20:33:03 -04:00
spouliot 7576761b70 Scope GL posting account lookups by CompanyId; cap sales-tax remittance (audit O3, O4)
O3: defense-in-depth on the write/posting path. Finding #7 scoped the report
(read) path; this scopes every GL posting-path account lookup that determines
where money lands, so a SuperAdmin acting in a company context can never post to
another tenant's account:
  - InvoicesController: all account-resolver helpers (checking, customer deposits,
    sales returns, customer credits, AR, bad debt, sales tax, sales discount, GC
    liability) plus the bank-account and write-off expense dropdowns
  - CreditMemosController: Create/Apply/Void GL lookups (scoped via the in-scope
    customer/invoice/memo)
  - GiftCertificatesController: Create/BulkCreate/Void GL lookups + GC liability helper
  - BillsController: AP/expense account resolution that pre-fills APAccountId
DepositsController and JournalEntriesController.SalesTaxPayment were already scoped.

O4: SalesTaxPayment now rejects a remittance greater than the outstanding Sales
Tax Payable balance (0.005 rounding tolerance), so a typo can no longer drive
2200 into an abnormal debit balance.

Remaining pure read-path dropdown lookups (app-wide, lower risk) are documented
in docs/ACCOUNTING_AUDIT.md as a separate follow-up. All audit findings O1-O4 are
now resolved. Build clean; 291 unit tests pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 19:48:53 -04:00
spouliot 517e452c64 Add inventory duplicate detection on add and label scan
Introduce a shared InventoryDuplicateMatcher (SKU, manufacturer part number,
manufacturer color) used by both manual inventory creation and powder-label
scanning, so the two paths flag duplicates consistently. Surfaces a duplicate
warning in the Create/Edit forms via inventory-duplicate-check.js and the
catalog-lookup / label-scan flows. Callers pass tenant-restricted inventory;
the matcher re-checks CompanyId as defense in depth.

Adds InventoryDuplicateMatcherTests covering the match precedence.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 19:39:24 -04:00
spouliot 1005be0c9e Fix Customer Deposits account mislabel and Sales Discounts recalc (audit O1, O2)
O1: account 2300 has always been used by the deposit GL code as the Customer
Deposits liability (resolved by number), but it was seeded/named "Payroll
Liabilities" for tenants the AccountingDepositsGL migration's NOT EXISTS guard
skipped — so the liability was mislabeled on the balance sheet. Rename 2300 to
"Customer Deposits" (IsSystem) and move payroll to a new 2400 account:
  - both seed paths (SeedDataService.Accounts, SeedData)
  - EnsureSystemAccountsAsync self-heal (renames only where still default-named,
    preserving user renames; ensures 2400 exists)
  - migration RenameDepositsAccountAddPayroll for existing tenants
Account number 2300 is unchanged, so the deposit posting code needs no changes.

O2: LedgerService never recomputed 4950 Sales Discounts, so "Recalculate
Balances" wiped it to JE-only and the Balance Reconciliation report showed false
drift. Add a 4950 section to GetAccountLedgerAsync and ComputePriorBalanceAsync
that reproduces the actual postings (invoice discounts DR + credit-memo issuance
DR, less the unapplied remainder of voided memos CR), matching AccountBalanceService.

Adds a LedgerService regression test for 4950. Documents both fixes plus the
remaining open findings (O3, O4) in docs/ACCOUNTING_AUDIT.md so the audit is no
longer lost. Build clean; 291 unit tests pass; migration applied.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 19:37:57 -04:00
spouliot 9532812b9f Scope every FinancialReportService query by CompanyId (defense in depth)
Audit finding #7: most report queries relied on the global tenant query filter,
which is bypassed for SuperAdmin users — so a SuperAdmin (or any multi-company
account) running P&L / Balance Sheet / Trial Balance / aging / statements could
pull data across companies. The cash-flow method was the only one doing it right
(IgnoreQueryFilters + explicit CompanyId).

Adds an explicit `CompanyId == companyId` predicate to every DB query across
GetProfitAndLossAsync, GetBalanceSheetAsync, GetTrialBalanceAsync, GetArAgingAsync,
GetSalesAndIncomeAsync, GetBalanceReconciliationAsync, and the customer/vendor
statements (Sales Tax and AP aging already had it). The remaining in-memory
filters operate on collections already loaded with the predicate. Matches the
repo's standing rule (explicit CompanyId on every query, never the global filter
alone). Build clean; 284 unit tests pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 13:54:34 -04:00
spouliot 0c921ba180 Add sales tax remittance flow
Audit finding #4: Sales Tax Payable was credited on every invoice but never
relieved (no remittance flow), so the liability grew forever. Adds a dedicated
"Record Sales Tax Payment" action that posts a balanced journal entry — DR Sales
Tax Payable (2200) / CR the chosen bank account — honoring the period lock.

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 09:11:05 -04:00
spouliot c2cd19e475 Add Balance Reconciliation report (detective control)
Phase 1 of the accounting audit remediation. A read-only diagnostic that surfaces
drift in the denormalized balances without changing any posting:

- Per account: stored Account.CurrentBalance vs the balance recomputed from source
  documents (the same LedgerService path RecalculateBalances uses). Drifted rows
  are highlighted; a difference means the cache is stale and a recalc would fix it.
- AR subledger (sum of Customer.CurrentBalance) vs the AR control account, and AP
  subledger (sum of Vendor.CurrentBalance) vs the AP control account.

FinancialReportService now takes ILedgerService to recompute. New
GetBalanceReconciliationAsync + BalanceReconciliationDto, a /Reports/Reconciliation
action, view, and a card on the reports landing. Build clean; 284 unit tests pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 09:05:16 -04:00
spouliot 9ce361235f Track store credit as a Customer Credits liability (GL)
Audit finding 9b: store-credit refunds and credit memos posted nothing to the GL
on issue (only a CreditMemo + Customer.CreditBalance), so outstanding store credit
was invisible on the balance sheet and the contra-revenue was recognized only on
apply. Introduces a 2350 "Customer Credits" liability so the credit is on the books
from issue to apply.

Model (chosen): lifecycle-equivalent to before, plus the liability is tracked.
- Issue (credit memos, goodwill, and store-credit refunds): DR Sales Discounts
  (4950) / CR Customer Credits (2350).
- Apply: DR Customer Credits / CR AR (was DR Sales Discounts / CR AR).
- Void unapplied remainder: DR Customer Credits / CR Sales Discounts.

Posting updated in all 8 sites: CreditMemosController Create/Apply/Void and
InvoicesController IssueCreditMemo/IssueRefund(store credit)/ApplyCredit/
VoidCreditMemo/CancelRefund. New 2350 account (seed + self-heal).

Reporting moved in lockstep so the books still balance: the 4950 contra-revenue
shifts from applied -> issued (active memos in full + applied portion of voided),
the 2350 liability = unapplied balance on active memos, AR still credited by
applications. Updated in FinancialReportService (balance sheet retained earnings,
trial balance, P&L) and LedgerService (per-account + prior-balance 2350 section).
Verified the balance-sheet identity for active and voided memos by hand; new
ledger test covers the 2350 lifecycle. Build clean; 284 unit tests pass.

Note: pre-existing quirks left untouched (out of 9b scope) — account 2300 is
seeded as "Payroll Liabilities" but resolved as Customer Deposits in code, and
LedgerService doesn't recompute 4950 so RecalculateBalances understates it; both
predate this change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 08:57:22 -04:00
spouliot 2a82a1d34b Make cash refunds reverse the sale instead of re-opening AR
Audit finding 9a: a cash refund posted DR AR (control up) while the customer
subledger went down — opposite directions, guaranteeing AR drift. Per the chosen
model, a cash refund now *reverses the sale*: DR Sales Returns (revenue portion)
+ DR Sales Tax Payable (tax portion) / CR Bank, with the customer AR balance left
untouched (the invoice stays paid; the sale is contra'd).

- New 4960 "Sales Returns & Allowances" contra-revenue account (seed + self-heal).
- Tax/revenue split centralized in Core RefundAllocation.Split so the posting and
  both reporting recomputes compute it identically (a mismatch would unbalance the
  trial balance).
- InvoicesController IssueRefund/CancelRefund: new posting + reversal; no AR/
  customer-balance change.
- LedgerService (per-account + prior balance): refunds debit Sales Returns + Sales
  Tax, no longer debit AR; bank credit unchanged.
- FinancialReportService: trial balance, balance sheet (retained earnings + tax
  liability), and P&L (contra-revenue line) all updated to match. Store-credit
  refunds are excluded everywhere (they post via CreditMemo, not the GL — 9b).

Verified: $108 refund (incl. $8 tax) -> bank -100... checks: assets -108 =
liabilities -8 + equity -100. New tests cover the split invariant and the ledger
postings. Build clean; 283 unit tests pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 19:58:42 -04:00
spouliot f54b945053 Preserve accounting linkages through CSV export/import round-trip
The data export was silently dropping account linkages, so an export->import
(used to copy prod data) lost which bank account each payment hit, all invoice
line items, and any bill/deposit/journal-entry detail. Diagnosed while cleaning
up a company's books from a copied dataset. Now every accounting linkage travels
by account/vendor/customer/invoice number — matching how expenses already worked
— so a round-trip preserves GL attribution instead of dropping it.

Payments: add DepositAccountNumber to the export + DTO; import resolves it back
to DepositAccountId so payments post to the right bank account on recalc.

Invoices: were header-only (re-imported invoices had 0 line items). Add a new
invoice_items CSV (one row per line, carrying RevenueAccountNumber) with export,
idempotent import, UI cards, and a template.

Bills / Deposits / Journal Entries: were not exported at all. Add full-fidelity
export + import including line-item children — bills + bill line items (vendor
by name, AP account + per-line expense account by number), deposits (customer +
bank account + applied invoice), and journal entries + JE lines (account by
number, debit/credit). 5 DTOs, 5 importers, 5 exporters + actions, all added to
the all_data export zip, plus 10 import/export UI cards.

Shared RunCsvImport helper added for the new import endpoints. All linkages
resolve by stable business keys (numbers/names), never internal IDs, so the
files round-trip across databases. Build clean; 278 unit tests pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 18:49:39 -04:00
spouliot f752abad86 Merge Columbia Coatings catalog integration
Full integration with the Columbia Coatings product catalog API: scheduled +
manual sync of the 2,410-product catalog, multi-manufacturer mapping (Columbia/
PPG/KP Pigments), additives categorization, swatch/tester/sample exclusion, and
tolerant cure/price/HTML parsing. Inventory tie-in (catalog link, discontinued
badge, auto-receive-from-catalog), right-to-delete purge, and quote-at-current-
catalog-price propagation with the cost basis kept separate for accounting.
Shared upsert across API sync and file import, lazy TDS spec enrichment, and
self-healing inventory links. 278 unit tests green.
2026-06-18 08:51:22 -04:00
spouliot 148a3f465e Self-heal inventory catalog links during sync
The sync propagation now also backfills the catalog link: any inventory item
with no PowderCatalogItemId that matches a catalog row by Manufacturer +
ManufacturerPartNumber (the catalog SKU) gets linked and picks up the catalog
price/product data. Only links on a confident match (exact SKU + matching
vendor, or a single unambiguous candidate), so it never mis-links.

This backfills items created before linking existed, automatically, on every
environment (dev and prod) with no manual step or one-off script — legacy items
link on the next sync, new items still link at create time. Cost basis,
quantity, notes, and image remain untouched.

Tests: links an unlinked item by manufacturer+part number; leaves it unlinked
when the part number has no catalog match. Full suite 278 green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 08:32:33 -04:00
spouliot a6538d9638 Add Columbia integration note to help docs
Brief, user-facing mention in both the AI help knowledge base and the human
Inventory help article: the catalog is integrated with the Columbia Coatings
product catalog, refreshes near real-time, auto-fills specs/SDS/TDS, keeps
prices current for quoting, and flags discontinued powders. No API/endpoint
detail, per intent.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 16:10:31 -04:00
spouliot 059d94d4fe Lazily enrich catalog specs from TDS on first use
Specific gravity, coverage, and ~55% of cure specs aren't in the Columbia feed.
Rather than read 2,400 TDS PDFs up front, enrich a catalog item the first time
it's actually used:

- FetchTdsCureSpecsAsync now also extracts specific gravity from the TDS.
- New EnsureCatalogTdsSpecsAsync fills a catalog item's specific gravity (and
  any missing cure temp/time) from its TDS, then derives theoretical coverage
  (192.3 / (SG x mils)). No-op once specific gravity is known or when there's no
  TDS; persists to the catalog so the work is done once and benefits everyone.
- Hooked into the catalog->inventory paths (CreateIncomingFromCatalog, the
  custom-powder receive enrichment, and ReceivePowderFromCatalog) so a powder's
  full specs land on both the catalog and the new inventory record. DashboardController
  gains the AI lookup service for this.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 16:07:23 -04:00
spouliot 8401bd77e8 Route Prismatic file import through the shared upsert
The manual JSON file import had its own insert/update loop; it now maps to
PowderCatalogItem and calls IPowderCatalogUpsertService.UpsertAsync — the same
path the Columbia API sync uses — so there is a single upsert/diff implementation
(and the file import now gets inventory propagation for free). Items are tagged
Source = "Manual JSON Import".

Also makes the shared upsert merge-not-wipe: it only overwrites a field when the
incoming feed provides a value (non-blank string, price > 0, nullable HasValue),
so a partial feed like the Prismatic scrape (no cure/chemistry) can't null out
data another source or enrichment populated.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 16:01:10 -04:00
spouliot 0f6eef5370 Show current catalog price and price-change nudge on inventory detail
Surfaces the synced CatalogReferencePrice on the inventory detail pricing card:
"Current Catalog Price" (the price quotes use), with an info popover clarifying
it doesn't affect Unit Cost or stock value, an "Updated <date>" line, and a
badge nudging when it differs from what they last paid ("Price up/down from
$X last paid"). Adds CatalogReferencePrice/CatalogPriceUpdatedAt to
InventoryItemDto (auto-mapped). Display only — no pricing/accounting impact.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 15:50:11 -04:00
spouliot c22537b68f Propagate catalog price to inventory and quote at current price
Quotes now reflect the current catalog price instead of a tenant's stale
typed-in cost, without disturbing accounting.

- InventoryItem gains CatalogReferencePrice + CatalogPriceUpdatedAt: the
  QUOTING price (current replacement cost), kept separate from UnitCost/
  AverageCost (the cost basis that drives valuation/COGS).
- The catalog sync (PowderCatalogUpsertService.PropagateToLinkedInventoryAsync,
  run at the end of every upsert) refreshes linked inventory items with the
  catalog's current price and product data (description, cure, SDS/TDS, color
  families, coverage, SG, transfer eff, requires-clear-coat). It NEVER touches
  cost, quantity, notes, image, location, or stock levels, and never nulls a
  tenant value with a catalog null. EF persists only actual changes.
- CatalogReferencePrice is also set at link time (catalog receive, incoming-
  from-catalog, identity match on create) so a freshly added powder quotes at
  the current price immediately.
- Pricing now uses CatalogReferencePrice ?? UnitCost: the quote/job powder
  pickers and PricingCalculationService (in-stock usage and powder-to-order
  billing). Falls back to UnitCost for non-catalog/manual powders, so nothing
  regresses. One current price for the whole quantity — no on-hand/to-order
  split. Per-coat snapshot still locks the price at quote creation.

Tests: propagation updates reference price + specs but not cost/qty/notes/
image, and skips a $0 catalog price. Full suite 276 green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 15:34:30 -04:00
spouliot 115ccf7d5e Auto-receive catalog powders and fix soft-deleted SKU collision
Two fixes to the "Got It" powder receive flow:

1. Skip the modal when the powder is in the master catalog. Clicking "Got It"
   now first calls ReceivePowderFromCatalog, which — if the powder resolves in
   the catalog — creates a fully populated inventory record (specs, cure, SDS/
   TDS, image, pricing) and marks the coat received, no modal. Only when the
   powder isn't in the catalog does it fall back to the manual entry modal.
   The catalog match/apply and the receive finalize (opening txn, mark received,
   sibling-coat linking) are extracted into shared helpers used by both the
   modal save and the auto-receive path.

2. Fix a crash re-receiving a previously-deleted powder. The unique index
   IX_InventoryItems_CompanyId_SKU had no filter, so a soft-deleted item still
   reserved its SKU; re-creating it generated the same SKU and violated the
   constraint. The index is now filtered on IsDeleted = 0, matching the app's
   soft-delete semantics.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 12:42:29 -04:00
spouliot 99b22d2ad2 Enrich received custom powders from the catalog
When a quote uses a powder the company doesn't stock but the master catalog
does, receiving it ("Got it") created an inventory record with only the color
code/name and cost carried on the quote — cure schedule, SDS/TDS, sample image,
color families, etc. were left blank.

AddCustomPowderToInventory now matches the platform powder catalog by SKU (the
coat's color code, preferring the same manufacturer, then by color name) and
fills every blank spec/document field from it, links PowderCatalogItemId, and
falls back to standard coverage/efficiency defaults. Only gaps are filled, so
anything entered on the receive form is preserved.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 12:21:50 -04:00
spouliot 6db055dcf8 Refine Columbia sync after first live run
Fixes from reviewing the first full sync of the real catalog:

- Exclude non-powder / size-variant listings: physical swatch cards (-SW /
  "SWATCH"), 4 oz testers (-04 / "Tester"), and 5 lb sample bags ("Sample (").
  These are not standalone powder colors. Filtered before mapping, and a
  cleanup step deletes any already synced (so they're removed, not flagged
  discontinued). Sample detection keys off the "Sample (" name, not the bare
  -S suffix, to avoid catching a real SKU ending in S (verified 0 collisions).
- Tighten RequiresClearCoat: was flagging ~53% of the catalog on any casual
  "clear coat" mention. Now only genuine signals (partial-cure schedules, the
  Illusion line, explicit "requires a clear" phrasing) trip it.
- Fix literal "&mdash;" in the sync success banner (TempData is HTML-encoded).

Tests cover the exclusion patterns and the tightened clear-coat detection.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 12:01:55 -04:00
spouliot eed61a298b Handle empty featured_image from Columbia feed
Products with no featured image return featured_image: [] (empty array) rather
than an object or null, which failed to bind to a single ColumbiaImage and
aborted the whole sync. Adds ColumbiaImageJsonConverter that reads the object
when present and yields null for any non-object form ([], false, ""), and drops
the unused GalleryImages property (we only use featured_image) to remove the
same risk. Regression tests cover both the empty-array and object cases.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 11:47:28 -04:00
spouliot 2286b5431d Make Columbia API base path configurable
The API namespace (/wp-json/cca/v1) was hardcoded; only the host was in config.
Adds a Columbia:ApiBasePath config key (default /wp-json/cca/v1) so an API
version bump is a config change, not a code change. The client now composes
the products URL from BaseUrl + ApiBasePath + /products. appsettings carries
the live key (private Gitea; Azure App Settings override in prod).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 11:38:32 -04:00
spouliot d2d9f44358 Add Columbia right-to-delete purge action
Phase 5 (part): compliance. PurgeColumbiaData (SuperAdmin) deletes every
catalog record whose Source is the Columbia Coatings API feed — regardless of
derived manufacturer, since PPG and KP Pigments products were served through
that feed — and nulls any inventory PowderCatalogItemId links across all
tenants. Tenant stock records are preserved (they keep their add-time snapshot,
losing only the live catalog link/badge), honoring the boundary that the
distributor's right-to-delete covers their catalog, not customers' purchased
stock. Adds a confirmed "Remove Columbia data" button to the catalog admin.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 11:28:29 -04:00
spouliot 4506c1f641 Link inventory to powder catalog and flag discontinued items
Phase 5 (part): the inventory tie-in.

- Set InventoryItem.PowderCatalogItemId on the catalog-sourced create paths:
  directly in CreateIncomingFromCatalog, and via a new FindCatalogMatchAsync
  (Manufacturer + ManufacturerPartNumber) helper in Create.
- Inventory Details loads the linked catalog row (falling back to an identity
  match for items created before linking) and shows a "Discontinued by
  manufacturer — cannot reorder" badge + banner when it's discontinued.
  Deliberately distinct from the shop's own Active/Inactive status: existing
  stock can still be used and quoted, it just can't be reordered.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 11:18:15 -04:00
spouliot a07f6aa1a8 Add scheduled Columbia catalog sync background service
Phase 4: automation. ColumbiaCatalogSyncBackgroundService wakes hourly and
runs a full sync only when ColumbiaSyncEnabled is on and ColumbiaSyncIntervalDays
has elapsed since the last successful run (tracked via the ColumbiaLastSyncedAt
setting). No-ops quietly when disabled or unconfigured. The hourly due-check is
negligible; the actual sync runs at most once per interval. Sync failures are
recorded on the result/settings, never thrown, so a bad run can't kill the loop.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 11:12:27 -04:00
spouliot 9aa3a99488 Add manual "Sync Columbia" action and status to powder catalog admin
Phase 3: SuperAdmin-triggered sync. Adds a SyncColumbia POST action that runs
a full catalog sync on demand (bypassing the schedule) and reports the result
via TempData. The catalog index header gains a "Sync Columbia" button (with a
syncing spinner) and a status line showing the scheduled-sync on/off state,
last-synced time, and last-run summary, read from the platform settings.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 11:05:21 -04:00
spouliot 2b420d4623 Add Columbia catalog mapper, shared upsert, and sync service
Phase 2: the mapping and sync core.

- ColumbiaCatalogMapper (pure/static, unit-tested): maps an API product to a
  PowderCatalogItem. Derives manufacturer (PPG/KP Pigments/Columbia) from
  taxonomy+SKU; flags additives into the Powder Additives category; takes base
  price from the top-level price with variant fallback; captures variation /
  tiered pricing as JSON; parses the free-text cure schedule into all curves
  (three degree glyphs, @/at, multi-curve in order, partial-cure -> none) with
  the first as the primary temp/time; strips HTML descriptions; joins color
  groups; normalizes chemistry; flags clear-coat powders.

- PowderCatalogUpsertService (IPowderCatalogUpsertService): single upsert path
  matching on (VendorName, SKU). Copies only feed-sourced fields and leaves
  enrichment fields (specific gravity, coverage, transfer efficiency, finish)
  untouched so syncs never wipe lazily-enriched TDS/AI data.

- ColumbiaCatalogSyncService (IColumbiaCatalogSyncService): pulls the full
  catalog, maps + de-dupes, upserts, then reconciles discontinuations ONLY on a
  complete pull (a partial pull throws and aborts before the sweep). Reactivates
  reappearing items; records last-synced/last-result platform settings.

- 25 mapper unit tests covering the cure parser, manufacturer derivation,
  simple/variable pricing, chemistry, color, and HTML cases from real records.
  Full suite green (261 passed).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 11:02:12 -04:00
spouliot a4a3dde7e4 Add single-product lookup methods to Columbia API client
Adds GetProductBySkuAsync (GET /products?sku=) and GetProductByIdAsync
(GET /products/{id}) for ad-hoc / on-demand refresh of a single record
without pulling the full catalog. Extracts the shared 429-retry send loop
into SendWithRetryAsync, which now also treats 404 as not-found (null) so
single lookups don't throw on a missing SKU/ID.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 10:51:02 -04:00
spouliot 39f61b9718 Add Columbia Coatings API client, DTOs, and sync settings
Phase 1b of the Columbia Coatings integration: the typed read client and
its configuration, ahead of the sync/mapper service.

- ColumbiaProductDtos: wire-shape models for GET /products. tiered_pricing
  is captured as JsonElement because the API returns it as an object on
  simple products but an empty array on variable ones — binding it raw
  avoids a deserialization throw; the mapper interprets it.
- IColumbiaCoatingsApiClient / ColumbiaCoatingsApiClient: pages the catalog
  via GET /products (NOT the export download_url, which is Cloudflare-blocked
  for server clients). Sends X-API-Key from config, honors 429/Retry-After,
  and THROWS on any page failure so a partial pull can never be mistaken for
  the full catalog (protects the later discontinuation sweep).
- ColumbiaIntegrationConstants: single home for config keys, setting keys,
  and the derived Source/manufacturer/category values.
- Config: Columbia:ApiKey (blank — secret supplied per environment) and
  Columbia:BaseUrl in appsettings.
- SeedColumbiaSyncSettings migration: seeds SuperAdmin-managed platform
  settings ColumbiaSyncEnabled (off by default), ColumbiaSyncIntervalDays
  (7), and last-sync tracking, under a new "Integrations" group.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 10:47:00 -04:00
spouliot c98f9faf63 Add Columbia Coatings catalog integration schema fields
Phase 1a of the Columbia Coatings API integration. Adds the persisted
fields the sync/mapper will need, ahead of the client and sync service:

PowderCatalogItem:
- Category: our product category (e.g. "Powder Additives" for gram-sold
  pigments) derived from vendor taxonomy at import, not stored raw.
- Source: provenance (e.g. "Columbia Coatings API"), kept separate from
  VendorName (= derived manufacturer) so a distributor's right-to-delete
  can purge by feed regardless of manufacturer.
- ChemistryType: resin chemistry (Polyester/TGIC/Epoxy/...), distinct
  from Finish.
- MilThickness: recommended film build as vendor free text.
- CureScheduleText: raw cure schedule verbatim (formats vary widely).
- CureCurvesJson: all parsed cure curves, so alternate low-temp curves
  are preserved for heat-sensitive substrates, not just the primary.
- FormulationChanges: vendor reformulation log; a signal cure specs may
  have changed.

InventoryItem:
- PowderCatalogItemId: loose link to the catalog row (matches the
  QuoteItemCoat pattern) so inventory detail can show manufacturer-level
  status (e.g. discontinued/cannot reorder) and future change flags.
  Nulled, never cascaded, when source catalog data is purged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 10:37:56 -04:00
121 changed files with 88422 additions and 716 deletions
+285
View File
@@ -0,0 +1,285 @@
# 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
@@ -0,0 +1,86 @@
# 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
@@ -0,0 +1,118 @@
# 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.
+130
View File
@@ -0,0 +1,130 @@
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
@@ -0,0 +1,46 @@
namespace PowderCoating.Application.Constants;
/// <summary>
/// Central constants for the Columbia Coatings catalog integration — config keys, platform-setting
/// keys, and the derived provenance/manufacturer/category values the mapper assigns. Kept in one
/// place so the API client, sync mapper, scheduled job, and purge logic all agree on the strings.
/// </summary>
public static class ColumbiaIntegrationConstants
{
// ── Configuration keys (appsettings.json / environment) ───────────────
/// <summary>API key — secret, lives in config not the platform-settings DB.</summary>
public const string ConfigApiKey = "Columbia:ApiKey";
public const string ConfigBaseUrl = "Columbia:BaseUrl";
/// <summary>Configurable API namespace/base path, so an API version bump is a config change.</summary>
public const string ConfigApiBasePath = "Columbia:ApiBasePath";
public const string DefaultBaseUrl = "https://columbiacoatings.com";
public const string DefaultApiBasePath = "/wp-json/cca/v1";
/// <summary>Resource segment appended to the API base path for product endpoints.</summary>
public const string ProductsResource = "/products";
/// <summary>API caps per_page at 100.</summary>
public const int MaxPerPage = 100;
// ── Platform setting keys (SuperAdmin-managed, non-secret) ────────────
public const string SettingEnabled = "ColumbiaSyncEnabled";
public const string SettingIntervalDays = "ColumbiaSyncIntervalDays";
public const string SettingLastSyncedAt = "ColumbiaLastSyncedAt";
public const string SettingLastResult = "ColumbiaLastSyncResult";
public const int DefaultSyncIntervalDays = 7;
// ── Provenance ────────────────────────────────────────────────────────
/// <summary>Stored in <c>PowderCatalogItem.Source</c> — the purge key for right-to-delete.</summary>
public const string SourceName = "Columbia Coatings API";
// ── Derived manufacturers (PowderCatalogItem.VendorName) ──────────────
public const string ManufacturerColumbia = "Columbia Coatings";
public const string ManufacturerPpg = "PPG";
public const string ManufacturerKp = "KP Pigments";
// ── Derived category (PowderCatalogItem.Category) ─────────────────────
public const string CategoryPowderAdditives = "Powder Additives";
}
@@ -154,6 +154,49 @@ public class TrialBalanceLine
public decimal CreditBalance { get; set; } 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 ───────────────────────────────────────────────────────────── // ── Profit & Loss ─────────────────────────────────────────────────────────────
public class ProfitAndLossDto public class ProfitAndLossDto
@@ -0,0 +1,49 @@
using System.Text.Json;
using System.Text.Json.Serialization;
namespace PowderCoating.Application.DTOs.Columbia;
/// <summary>
/// Tolerant converter for Columbia image fields. WordPress/WooCommerce returns an object
/// (<c>{id,src,name,alt}</c>) when an image is present, but an empty array (<c>[]</c>) — or
/// sometimes <c>false</c>/empty string — when it is not. A single <see cref="ColumbiaImage"/>
/// can't bind to those non-object forms, so this converter reads the object when present and
/// yields null for anything else (consuming the token so deserialization continues).
/// </summary>
public class ColumbiaImageJsonConverter : JsonConverter<ColumbiaImage?>
{
public override ColumbiaImage? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
switch (reader.TokenType)
{
case JsonTokenType.StartObject:
using (var doc = JsonDocument.ParseValue(ref reader))
{
var el = doc.RootElement;
return new ColumbiaImage
{
Id = el.TryGetProperty("id", out var id) && id.TryGetInt32(out var i) ? i : 0,
Src = GetString(el, "src"),
Name = GetString(el, "name"),
Alt = GetString(el, "alt"),
};
}
case JsonTokenType.StartArray:
reader.Skip(); // empty/non-empty array form means "no image"
return null;
default:
// Primitive (false / "" / null / number): nothing to consume further.
return null;
}
}
public override void Write(Utf8JsonWriter writer, ColumbiaImage? value, JsonSerializerOptions options)
=> throw new NotSupportedException("Columbia image fields are read-only.");
private static string GetString(JsonElement el, string property) =>
el.TryGetProperty(property, out var v) && v.ValueKind == JsonValueKind.String
? v.GetString() ?? string.Empty
: string.Empty;
}
@@ -0,0 +1,142 @@
using System.Text.Json;
using System.Text.Json.Serialization;
namespace PowderCoating.Application.DTOs.Columbia;
/// <summary>
/// One page of the Columbia Coatings <c>GET /products</c> response: a list of products plus
/// pagination metadata. Property names are snake_case in the API and bound via the snake-case
/// naming policy configured on the client's <see cref="JsonSerializerOptions"/>.
/// </summary>
public class ColumbiaProductsResponse
{
public List<ColumbiaProduct> Items { get; set; } = new();
public ColumbiaPagination? Pagination { get; set; }
}
/// <summary>Pagination block returned alongside a product page.</summary>
public class ColumbiaPagination
{
public int Page { get; set; }
public int PerPage { get; set; }
public int Total { get; set; }
public int TotalPages { get; set; }
}
/// <summary>
/// A single Columbia Coatings product as returned by the API. This mirrors the wire shape, not our
/// catalog model — mapping into <c>PowderCatalogItem</c> happens in the sync mapper. Prices arrive
/// as strings; cure/spec fields are free text; documents are direct URLs.
/// </summary>
public class ColumbiaProduct
{
public int Id { get; set; }
/// <summary>"simple" or "variable". Variable products carry packaging/size variants in
/// <see cref="VariationPricing"/> and leave <see cref="TieredPricing"/> as an empty array.</summary>
public string ProductType { get; set; } = string.Empty;
public string Name { get; set; } = string.Empty;
public string Slug { get; set; } = string.Empty;
public string Sku { get; set; } = string.Empty;
public string Permalink { get; set; } = string.Empty;
public string Status { get; set; } = string.Empty;
/// <summary>Columbia-specific values seen include "In Stock"/"instock", "formulated",
/// "drop_shipped", "multiple_variations", "outofstock", "onbackorder" — mixed casing.</summary>
public string StockStatus { get; set; } = string.Empty;
// ── Pricing (all strings on the wire) ─────────────────────────────────
public string Price { get; set; } = string.Empty;
public string RegularPrice { get; set; } = string.Empty;
public string SalePrice { get; set; } = string.Empty;
/// <summary>
/// Quantity-break pricing. POLYMORPHIC on the wire: an object
/// (<c>{type, minimum_quantity, tiers:[...]}</c>) on simple products, but an empty ARRAY
/// (<c>[]</c>) on variable products. Captured as a nullable raw <see cref="JsonElement"/> so
/// deserialization never throws on the type mismatch and an absent value is null (not an
/// invalid <c>Undefined</c> element); the mapper interprets it.
/// </summary>
public JsonElement? TieredPricing { get; set; }
/// <summary>Per-variant pricing for variable products (e.g. Bulk vs 1 lb Bags, or gram sizes).
/// Each variant has its own SKU and price. Empty for simple products.</summary>
public List<ColumbiaVariationPricing>? VariationPricing { get; set; }
// ── Documents (direct URLs) ───────────────────────────────────────────
public string SafetyDataSheet { get; set; } = string.Empty;
public string TechnicalDataSheet { get; set; } = string.Empty;
public string ProductFlyer { get; set; } = string.Empty;
public string ProductBrochure { get; set; } = string.Empty;
// ── Coating spec free-text ────────────────────────────────────────────
public string CureSchedule { get; set; } = string.Empty;
public string MilThickness { get; set; } = string.Empty;
/// <summary>Resin chemistry (e.g. "Polyester/TGIC", "TGIC", "Epoxy"). NOT finish/gloss.</summary>
public string Type { get; set; } = string.Empty;
public string ReleaseDate { get; set; } = string.Empty;
public string FormulationDate { get; set; } = string.Empty;
/// <summary>Free-text reformulation log, e.g. "Formulation Change: 05/22/26".</summary>
public string FormulationDateChanges { get; set; } = string.Empty;
// ── Content ───────────────────────────────────────────────────────────
/// <summary>HTML product description (WordPress markup).</summary>
public string Description { get; set; } = string.Empty;
public string ShortDescription { get; set; } = string.Empty;
public ColumbiaImage? FeaturedImage { get; set; }
// ── Taxonomy (arrays of {name}) — used at import to derive manufacturer/category, not stored raw ──
public List<ColumbiaNamed> Categories { get; set; } = new();
public List<ColumbiaNamed> Tags { get; set; } = new();
public List<ColumbiaNamed> PaColorGroup { get; set; } = new();
public List<ColumbiaAttribute> Attributes { get; set; } = new();
}
/// <summary>A pricing variant of a variable product (own SKU, own price/tiers).</summary>
public class ColumbiaVariationPricing
{
public int Id { get; set; }
public string Sku { get; set; } = string.Empty;
public List<ColumbiaAttributeValue> Attributes { get; set; } = new();
public string StockStatus { get; set; } = string.Empty;
public string Price { get; set; } = string.Empty;
public string RegularPrice { get; set; } = string.Empty;
public string SalePrice { get; set; } = string.Empty;
/// <summary>Same polymorphic object-or-array shape as the parent; captured raw (nullable).</summary>
public JsonElement? TieredPricing { get; set; }
}
/// <summary>An image object — featured or gallery.</summary>
public class ColumbiaImage
{
public int Id { get; set; }
public string Src { get; set; } = string.Empty;
public string Name { get; set; } = string.Empty;
public string Alt { get; set; } = string.Empty;
}
/// <summary>A simple <c>{name}</c> taxonomy entry (category, tag, or color group).</summary>
public class ColumbiaNamed
{
public string Name { get; set; } = string.Empty;
}
/// <summary>A product attribute with its option list, e.g. Color Group → [Blue], Packaging → [Bulk, 1 lb Bags].</summary>
public class ColumbiaAttribute
{
public string Name { get; set; } = string.Empty;
public List<ColumbiaNamed> Options { get; set; } = new();
}
/// <summary>A resolved attribute value on a specific variation, e.g. Packaging → "Bulk".</summary>
public class ColumbiaAttributeValue
{
public string Name { get; set; } = string.Empty;
public string Value { get; set; } = string.Empty;
}
@@ -0,0 +1,55 @@
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; }
}
@@ -0,0 +1,37 @@
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; }
}
@@ -0,0 +1,46 @@
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; }
}
@@ -0,0 +1,44 @@
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; }
}
@@ -0,0 +1,27 @@
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";
}
@@ -0,0 +1,31 @@
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,6 +24,14 @@ public class PaymentImportDto
[Name("PaymentMethod")] [Name("PaymentMethod")]
public string PaymentMethod { get; set; } = "Cash"; 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")] [Name("Reference")]
public string? Reference { get; set; } public string? Reference { get; set; }
@@ -37,6 +37,8 @@ public class InventoryItemDto
public decimal AverageCost { get; set; } public decimal AverageCost { get; set; }
public decimal LastPurchasePrice { get; set; } public decimal LastPurchasePrice { get; set; }
public DateTime? LastPurchaseDate { get; set; } public DateTime? LastPurchaseDate { get; set; }
public decimal? CatalogReferencePrice { get; set; }
public DateTime? CatalogPriceUpdatedAt { get; set; }
public int? PrimaryVendorId { get; set; } public int? PrimaryVendorId { get; set; }
public string? PrimaryVendorName { get; set; } public string? PrimaryVendorName { get; set; }
public string? VendorPartNumber { get; set; } public string? VendorPartNumber { get; set; }
@@ -223,6 +225,12 @@ public class CreateInventoryItemDto
[Display(Name = "Incoming / On Order")] [Display(Name = "Incoming / On Order")]
public bool IsIncoming { get; set; } 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 public class UpdateInventoryItemDto : CreateInventoryItemDto
@@ -0,0 +1,41 @@
namespace PowderCoating.Application.Interfaces;
/// <summary>
/// Orchestrates a full Columbia Coatings catalog sync: pull every product, map and upsert it, then
/// (only on a complete pull) reconcile discontinuations. Used by both the scheduled background job
/// and the manual "Sync now" admin action.
/// </summary>
public interface IColumbiaCatalogSyncService
{
/// <summary>
/// Runs one full sync. Assumes the caller has already decided it should run (enabled / due).
/// Returns a result describing the outcome; never throws for an expected failure (not
/// configured, partial pull, HTTP error) — those are reported on the result instead.
/// </summary>
Task<ColumbiaSyncResult> RunSyncAsync(CancellationToken cancellationToken = default);
}
/// <summary>Outcome of a Columbia catalog sync run.</summary>
public class ColumbiaSyncResult
{
public bool Success { get; set; }
public string? ErrorMessage { get; set; }
public int TotalFetched { get; set; }
public int Inserted { get; set; }
public int Updated { get; set; }
public int Unchanged { get; set; }
public int Skipped { get; set; }
public int Discontinued { get; set; }
public int Reactivated { get; set; }
public DateTime StartedAt { get; set; }
public TimeSpan Duration { get; set; }
/// <summary>One-line summary suitable for storing in the last-result platform setting / UI.</summary>
public string Summary =>
Success
? $"{TotalFetched} fetched: {Inserted} new, {Updated} updated, {Unchanged} unchanged, " +
$"{Discontinued} discontinued, {Reactivated} reactivated ({Duration.TotalSeconds:F0}s)"
: $"Failed: {ErrorMessage}";
}
@@ -0,0 +1,47 @@
using PowderCoating.Application.DTOs.Columbia;
namespace PowderCoating.Application.Interfaces;
/// <summary>
/// Typed client for the Columbia Coatings product catalog API (<c>/wp-json/cca/v1</c>).
/// Read-only: lists products via the paged <c>GET /products</c> endpoint.
/// <para>
/// We deliberately page <c>/products</c> rather than using the bulk <c>export.json</c> download:
/// the export returns a temporary <c>download_url</c> to a static file under <c>/wp-content/uploads</c>,
/// which sits behind Cloudflare bot protection and 403s for non-browser clients. The
/// <c>/wp-json</c> API routes are allowlisted via the API key, so paging is the only path that
/// works reliably from a server.
/// </para>
/// </summary>
public interface IColumbiaCoatingsApiClient
{
/// <summary>
/// True when an API key is configured (<c>Columbia:ApiKey</c>). When false, callers should
/// skip the sync entirely rather than issue unauthenticated requests.
/// </summary>
bool IsConfigured { get; }
/// <summary>
/// Retrieves a single page of products. <paramref name="perPage"/> is capped at 100 by the API.
/// </summary>
Task<ColumbiaProductsResponse> GetProductsPageAsync(int page, int perPage, CancellationToken cancellationToken = default);
/// <summary>
/// Pages through the entire catalog and returns every product. Honors rate limiting
/// (429 / Retry-After). THROWS if any page fails after retries — callers must treat an
/// exception as "incomplete pull" and NOT run discontinuation logic against a partial set.
/// </summary>
Task<IReadOnlyList<ColumbiaProduct>> GetAllProductsAsync(CancellationToken cancellationToken = default);
/// <summary>
/// Fetches a single product by exact SKU (<c>GET /products?sku=...</c>), or null if not found.
/// For ad-hoc refresh of one record without pulling the whole catalog.
/// </summary>
Task<ColumbiaProduct?> GetProductBySkuAsync(string sku, CancellationToken cancellationToken = default);
/// <summary>
/// Fetches a single product by WooCommerce product ID (<c>GET /products/{id}</c>), or null if
/// not found. Useful when we already store the catalog product's ID and want to refresh it.
/// </summary>
Task<ColumbiaProduct?> GetProductByIdAsync(int id, CancellationToken cancellationToken = default);
}
@@ -179,10 +179,36 @@ public interface ICsvImportService
/// <summary> /// <summary>
/// Import invoice headers from a CSV stream. Customers are resolved by CustomerEmail then /// Import invoice headers from a CSV stream. Customers are resolved by CustomerEmail then
/// CustomerName. Duplicate detection uses InvoiceNumber as the unique key. Existing invoices /// CustomerName. Duplicate detection uses InvoiceNumber as the unique key. Existing invoices
/// are updated; new ones are created. Line items are not part of the CSV format. /// are updated; new ones are created. Line items are imported separately via
/// <see cref="ImportInvoiceItemsAsync"/>.
/// </summary> /// </summary>
Task<CsvImportResultDto> ImportInvoicesAsync(Stream csvStream, int companyId); 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> /// <summary>Generate a CSV template file for payment imports.</summary>
byte[] GeneratePaymentTemplate(); byte[] GeneratePaymentTemplate();
@@ -33,6 +33,12 @@ public interface IFinancialReportService
/// <summary>Returns a Trial Balance using current account balances as of the given date.</summary> /// <summary>Returns a Trial Balance using current account balances as of the given date.</summary>
Task<TrialBalanceDto> GetTrialBalanceAsync(int companyId, DateTime asOf); 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> /// <summary>Looks up the accounting method configured for the given company. Returns Accrual if not found.</summary>
Task<AccountingMethod> GetCompanyAccountingMethodAsync(int companyId); Task<AccountingMethod> GetCompanyAccountingMethodAsync(int companyId);
@@ -59,9 +59,17 @@ public interface IInventoryAiLookupService
Task<InventoryAiLookupResult> ScanLabelAsync(string base64Image, string mediaType); Task<InventoryAiLookupResult> ScanLabelAsync(string base64Image, string mediaType);
/// <summary> /// <summary>
/// Fetches a Technical Data Sheet URL and extracts cure temperature and cure time. /// Fetches a Technical Data Sheet URL and extracts cure temperature, cure time, and specific
/// Called when the main lookup found a TDS URL but cure specs are still missing. /// gravity. Called when the main lookup found a TDS URL but specs are still missing.
/// Returns Success=false silently (no UI error) when the TDS is a PDF or unreachable. /// Returns Success=false silently (no UI error) when the TDS is a PDF or unreachable.
/// </summary> /// </summary>
Task<InventoryAiLookupResult> FetchTdsCureSpecsAsync(string tdsUrl, string? colorName); Task<InventoryAiLookupResult> FetchTdsCureSpecsAsync(string tdsUrl, string? colorName);
/// <summary>
/// Lazily fills a powder catalog item's specific gravity (and any missing cure specs) from its
/// TDS the first time it's needed, then derives theoretical coverage. No-op when specific
/// gravity is already known or no TDS URL is present. Persists the enrichment to the catalog so
/// it's done once and benefits every future use. Returns true if anything was filled.
/// </summary>
Task<bool> EnsureCatalogTdsSpecsAsync(PowderCoating.Core.Entities.PowderCatalogItem catalog);
} }
@@ -0,0 +1,32 @@
using PowderCoating.Core.Entities;
namespace PowderCoating.Application.Interfaces;
/// <summary>
/// Shared upsert for the platform powder catalog: matches incoming records to existing rows by
/// (VendorName, SKU), inserts new ones, and updates changed ones in place. Used by BOTH the manual
/// JSON file import and the Columbia API sync so there is a single upsert path, only the mapping
/// differs. Does NOT handle discontinuation — that is a sync-specific concern.
/// </summary>
public interface IPowderCatalogUpsertService
{
/// <summary>
/// Applies <paramref name="incoming"/> mapped catalog items. Only fields sourced from the feed
/// are copied on update; enrichment fields (specific gravity, coverage, transfer efficiency,
/// finish) are preserved so they are not wiped by a feed that never carries them. Changed and
/// inserted rows get <paramref name="runTimestamp"/> stamped on LastSyncedAt/UpdatedAt.
/// </summary>
Task<PowderCatalogUpsertResult> UpsertAsync(
IReadOnlyList<PowderCatalogItem> incoming,
DateTime runTimestamp,
CancellationToken cancellationToken = default);
}
/// <summary>Counts from an upsert run.</summary>
public class PowderCatalogUpsertResult
{
public int Inserted { get; set; }
public int Updated { get; set; }
public int Unchanged { get; set; }
public int Skipped { get; set; }
}
@@ -0,0 +1,91 @@
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();
}
}
@@ -149,9 +149,12 @@ public class PricingCalculationService : IPricingCalculationService
try try
{ {
var inventoryItem = await _unitOfWork.InventoryItems.GetByIdAsync(coat.InventoryItemId.Value); var inventoryItem = await _unitOfWork.InventoryItems.GetByIdAsync(coat.InventoryItemId.Value);
if (inventoryItem != null && inventoryItem.UnitCost > 0) // Prefer the current catalog price (replacement cost) so quotes reflect the latest
// price; fall back to the item's own cost when it isn't catalog-linked.
var effectiveCostPerLb = inventoryItem?.CatalogReferencePrice ?? inventoryItem?.UnitCost ?? 0m;
if (inventoryItem != null && effectiveCostPerLb > 0)
{ {
costPerLb = inventoryItem.UnitCost; costPerLb = effectiveCostPerLb;
isIncomingPowder = inventoryItem.IsIncoming; isIncomingPowder = inventoryItem.IsIncoming;
var coverage = coat.CoverageSqFtPerLb; var coverage = coat.CoverageSqFtPerLb;
var transferEfficiency = coat.TransferEfficiency; var transferEfficiency = coat.TransferEfficiency;
@@ -160,8 +163,8 @@ public class PricingCalculationService : IPricingCalculationService
var actualPoundsPerSqFt = poundsPerSqFt / (transferEfficiency / 100m); var actualPoundsPerSqFt = poundsPerSqFt / (transferEfficiency / 100m);
powderCostPerSqFt = actualPoundsPerSqFt * costPerLb; powderCostPerSqFt = actualPoundsPerSqFt * costPerLb;
_logger.LogInformation("Coat {CoatName}: Using inventory item: {InventoryItem} (IsIncoming={IsIncoming}), UnitCost={UnitCost}/lb, Coverage={Coverage}sqft/lb, Efficiency={Efficiency}%, Calculated={CalcCost}/sqft", _logger.LogInformation("Coat {CoatName}: Using inventory item: {InventoryItem} (IsIncoming={IsIncoming}), CostPerLb={CostPerLb}/lb (catalog ref={CatalogRef}), Coverage={Coverage}sqft/lb, Efficiency={Efficiency}%, Calculated={CalcCost}/sqft",
coat.CoatName, inventoryItem.Name, isIncomingPowder, inventoryItem.UnitCost, coverage, transferEfficiency, powderCostPerSqFt); coat.CoatName, inventoryItem.Name, isIncomingPowder, costPerLb, inventoryItem.CatalogReferencePrice, coverage, transferEfficiency, powderCostPerSqFt);
} }
} }
catch (Exception ex) catch (Exception ex)
@@ -691,7 +694,8 @@ public class PricingCalculationService : IPricingCalculationService
var invItem = await _unitOfWork.InventoryItems.GetByIdAsync(c.InventoryItemId.Value); var invItem = await _unitOfWork.InventoryItems.GetByIdAsync(c.InventoryItemId.Value);
if (invItem?.IsIncoming == true) if (invItem?.IsIncoming == true)
{ {
customPowderOrderAmount += c.PowderToOrder.Value * invItem.UnitCost; // Bill the powder-to-order at the current catalog price when linked.
customPowderOrderAmount += c.PowderToOrder.Value * (invItem.CatalogReferencePrice ?? invItem.UnitCost);
var colorName = !string.IsNullOrWhiteSpace(c.ColorName) ? c.ColorName : invItem.Name; var colorName = !string.IsNullOrWhiteSpace(c.ColorName) ? c.ColorName : invItem.Name;
if (!string.IsNullOrWhiteSpace(colorName)) if (!string.IsNullOrWhiteSpace(colorName))
customPowderOrderColors.Add(colorName); customPowderOrderColors.Add(colorName);
@@ -0,0 +1,29 @@
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,6 +18,18 @@ public class CompanyPreferences : BaseEntity
public string InvoiceNumberPrefix { get; set; } = "INV"; public string InvoiceNumberPrefix { get; set; } = "INV";
public bool UseMetricSystem { get; set; } = false; // False = Imperial (ft, lb), True = Metric (m, kg) public bool UseMetricSystem { get; set; } = false; // False = Imperial (ft, lb), True = Metric (m, kg)
// Default GL Accounts — used as the fallback when an item leaves its account field blank.
// Null means "no default": revenue falls back to account 4000, and inventory-consumption
// COGS simply isn't posted (consistent with expensing materials at purchase). A company
// only opts into perpetual-inventory COGS posting by setting both the COGS and Inventory
// defaults (or the per-item accounts). FKs are nullable with no cascade — accounts soft-delete.
/// <summary>Default Revenue account for invoice lines that don't specify one (fallback before account 4000).</summary>
public int? DefaultRevenueAccountId { get; set; }
/// <summary>Default COGS account pre-filled on new inventory/catalog items. Drives inventory-consumption COGS posting when paired with an inventory account.</summary>
public int? DefaultCogsAccountId { get; set; }
/// <summary>Default Inventory asset account pre-filled on new inventory items. Drives inventory-consumption COGS posting when paired with a COGS account.</summary>
public int? DefaultInventoryAccountId { get; set; }
// Job / Workflow Defaults // Job / Workflow Defaults
public string DefaultJobPriority { get; set; } = "Normal"; public string DefaultJobPriority { get; set; } = "Normal";
public bool RequireCustomerPO { get; set; } = false; public bool RequireCustomerPO { get; set; } = false;
@@ -31,6 +31,27 @@ public class InventoryItem : BaseEntity
public string? SdsUrl { get; set; } // Safety Data Sheet URL (from powder catalog or manual entry) public string? SdsUrl { get; set; } // Safety Data Sheet URL (from powder catalog or manual entry)
public string? TdsUrl { get; set; } // Technical Data Sheet URL (from powder catalog or manual entry) public string? TdsUrl { get; set; } // Technical Data Sheet URL (from powder catalog or manual entry)
/// <summary>
/// Optional link to the platform powder catalog record this item was created from.
/// Populated when an item is added via the catalog lookup, or back-filled by Manufacturer+SKU.
/// Lets the inventory detail screen surface manufacturer-level status (e.g. "discontinued by
/// manufacturer — cannot reorder") and future price/reformulation change flags. Nulled — not
/// cascaded — if the source catalog data is purged (the shop's own stock record must survive).
/// </summary>
public int? PowderCatalogItemId { get; set; }
/// <summary>
/// Latest list price from the linked powder catalog, refreshed by the catalog sync. This is the
/// QUOTING price (current replacement cost) and is kept deliberately SEPARATE from
/// <see cref="UnitCost"/>/<see cref="AverageCost"/> (the actual paid cost basis that drives
/// inventory valuation and COGS). Quoting prefers this when present so quotes reflect the
/// current price; accounting never reads it. Null for manual/non-catalog powders.
/// </summary>
public decimal? CatalogReferencePrice { get; set; }
/// <summary>Timestamp (UTC) when <see cref="CatalogReferencePrice"/> was last refreshed by the sync.</summary>
public DateTime? CatalogPriceUpdatedAt { get; set; }
// Sample Panel Tracking (coating category items only) // Sample Panel Tracking (coating category items only)
public bool HasSamplePanel { get; set; } = false; public bool HasSamplePanel { get; set; } = false;
@@ -40,9 +40,30 @@ public class PowderCatalogItem
/// <summary>Cure hold time at cure temperature, in minutes.</summary> /// <summary>Cure hold time at cure temperature, in minutes.</summary>
public int? CureTimeMinutes { get; set; } public int? CureTimeMinutes { get; set; }
/// <summary>
/// Raw cure schedule text exactly as supplied by the vendor — e.g. "10 minutes @ 400°F".
/// Preserved verbatim because vendor formats vary wildly and some carry application notes
/// that don't reduce to a single temp/time pair (partial cures, clear-coat steps).
/// </summary>
public string? CureScheduleText { get; set; }
/// <summary>
/// All parsed cure curves as JSON — e.g. [{"tempF":400,"minutes":10},{"tempF":350,"minutes":20}].
/// Many powders list alternate lower-temperature curves; these matter for heat-sensitive
/// substrates that cannot take the standard 400°F cure, so we keep every curve, not just the
/// primary one in <see cref="CureTemperatureF"/>/<see cref="CureTimeMinutes"/>.
/// </summary>
public string? CureCurvesJson { get; set; }
/// <summary>Finish type — e.g. Gloss, Matte, Satin, Metallic, Texture.</summary> /// <summary>Finish type — e.g. Gloss, Matte, Satin, Metallic, Texture.</summary>
public string? Finish { get; set; } public string? Finish { get; set; }
/// <summary>Resin chemistry — e.g. "Polyester", "TGIC", "Epoxy", "Hybrid". Distinct from <see cref="Finish"/>.</summary>
public string? ChemistryType { get; set; }
/// <summary>Recommended film build (mil thickness) as free text from the vendor — e.g. "2.0-3.0 Mils".</summary>
public string? MilThickness { get; set; }
/// <summary>Comma-separated color family tags — e.g. "Blue,Purple".</summary> /// <summary>Comma-separated color family tags — e.g. "Blue,Purple".</summary>
public string? ColorFamilies { get; set; } public string? ColorFamilies { get; set; }
@@ -60,6 +81,29 @@ public class PowderCatalogItem
// ── Catalog management ──────────────────────────────────────────────── // ── Catalog management ────────────────────────────────────────────────
/// <summary>
/// Our internal product category — e.g. "Powder Additives" for pigments/additives that are
/// sold by weight in grams and mixed into clear rather than sprayed as a standalone powder.
/// Null/empty for standard powders. Derived at import from the vendor's taxonomy, NOT stored
/// from their raw category list.
/// </summary>
public string? Category { get; set; }
/// <summary>
/// Provenance of this record — e.g. "Columbia Coatings API". Kept SEPARATE from
/// <see cref="VendorName"/> (which holds the derived manufacturer) so we can honor a
/// distributor's right-to-delete by purging every record that came from their feed,
/// regardless of which manufacturer made the product.
/// </summary>
public string? Source { get; set; }
/// <summary>
/// Reformulation history as supplied by the vendor — e.g. "Formulation Change: 05/22/26".
/// Not a reliable modified-date (free text, reformulations only) but a useful signal that a
/// product's formula — and therefore its cure specs — may have changed.
/// </summary>
public string? FormulationChanges { get; set; }
/// <summary>True when the vendor has discontinued this product. Kept for historical lookups.</summary> /// <summary>True when the vendor has discontinued this product. Kept for historical lookups.</summary>
public bool IsDiscontinued { get; set; } = false; public bool IsDiscontinued { get; set; } = false;
@@ -0,0 +1,48 @@
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,6 +946,26 @@ modelBuilder.Entity<ReworkRecord>().HasQueryFilter(e =>
.HasForeignKey(i => i.CogsAccountId) .HasForeignKey(i => i.CogsAccountId)
.OnDelete(DeleteBehavior.NoAction); .OnDelete(DeleteBehavior.NoAction);
// CompanyPreferences → Default Revenue / COGS / Inventory accounts (nullable, no cascade,
// no navigation property — accounts use soft delete and these are config pointers only).
modelBuilder.Entity<CompanyPreferences>()
.HasOne<Account>()
.WithMany()
.HasForeignKey(p => p.DefaultRevenueAccountId)
.OnDelete(DeleteBehavior.NoAction);
modelBuilder.Entity<CompanyPreferences>()
.HasOne<Account>()
.WithMany()
.HasForeignKey(p => p.DefaultCogsAccountId)
.OnDelete(DeleteBehavior.NoAction);
modelBuilder.Entity<CompanyPreferences>()
.HasOne<Account>()
.WithMany()
.HasForeignKey(p => p.DefaultInventoryAccountId)
.OnDelete(DeleteBehavior.NoAction);
// CatalogItem → RevenueAccount / CogsAccount (nullable, no cascade — accounts use soft delete) // CatalogItem → RevenueAccount / CogsAccount (nullable, no cascade — accounts use soft delete)
modelBuilder.Entity<CatalogItem>() modelBuilder.Entity<CatalogItem>()
.HasOne(ci => ci.RevenueAccount) .HasOne(ci => ci.RevenueAccount)
@@ -1511,6 +1531,9 @@ modelBuilder.Entity<Job>()
modelBuilder.Entity<InventoryItem>() modelBuilder.Entity<InventoryItem>()
.HasIndex(i => new { i.CompanyId, i.SKU }) .HasIndex(i => new { i.CompanyId, i.SKU })
.IsUnique() .IsUnique()
// Filter on IsDeleted so soft-deleted items don't reserve their SKU and block a new
// (or re-created) item from reusing it — matching the app's soft-delete semantics.
.HasFilter("[IsDeleted] = 0")
.HasDatabaseName("IX_InventoryItems_CompanyId_SKU"); .HasDatabaseName("IX_InventoryItems_CompanyId_SKU");
modelBuilder.Entity<Company>() modelBuilder.Entity<Company>()
@@ -1359,8 +1359,12 @@ 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 = "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 = "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 }, new() { AccountNumber = "2200", Name = "Sales Tax Payable", AccountType = AccountType.Liability, AccountSubType = AccountSubType.OtherCurrentLiability, 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 }, // 2300 = Customer Deposits liability (resolved by number in the deposit GL posting code); payroll is at 2400.
new() { AccountNumber = "2500", Name = "Long-Term Loan", AccountType = AccountType.Liability, AccountSubType = AccountSubType.LongTermLiability, IsSystem = false, IsActive = true, CompanyId = company.Id, CreatedAt = now }, 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 },
// ── Equity ────────────────────────────────────────────────────── // ── Equity ──────────────────────────────────────────────────────
new() { AccountNumber = "3000", Name = "Owner's Equity", AccountType = AccountType.Equity, AccountSubType = AccountSubType.OwnersEquity, IsSystem = true, IsActive = true, CompanyId = company.Id, CreatedAt = now }, new() { AccountNumber = "3000", Name = "Owner's Equity", AccountType = AccountType.Equity, AccountSubType = AccountSubType.OwnersEquity, IsSystem = true, IsActive = true, CompanyId = company.Id, CreatedAt = now },
@@ -0,0 +1,141 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace PowderCoating.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class AddColumbiaCatalogIntegrationFields : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "Category",
table: "PowderCatalogItems",
type: "nvarchar(max)",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "ChemistryType",
table: "PowderCatalogItems",
type: "nvarchar(max)",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "CureCurvesJson",
table: "PowderCatalogItems",
type: "nvarchar(max)",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "CureScheduleText",
table: "PowderCatalogItems",
type: "nvarchar(max)",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "FormulationChanges",
table: "PowderCatalogItems",
type: "nvarchar(max)",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "MilThickness",
table: "PowderCatalogItems",
type: "nvarchar(max)",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "Source",
table: "PowderCatalogItems",
type: "nvarchar(max)",
nullable: true);
migrationBuilder.AddColumn<int>(
name: "PowderCatalogItemId",
table: "InventoryItems",
type: "int",
nullable: true);
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 1,
column: "CreatedAt",
value: new DateTime(2026, 6, 17, 14, 21, 27, 126, DateTimeKind.Utc).AddTicks(6644));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 2,
column: "CreatedAt",
value: new DateTime(2026, 6, 17, 14, 21, 27, 126, DateTimeKind.Utc).AddTicks(6651));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 3,
column: "CreatedAt",
value: new DateTime(2026, 6, 17, 14, 21, 27, 126, DateTimeKind.Utc).AddTicks(6652));
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "Category",
table: "PowderCatalogItems");
migrationBuilder.DropColumn(
name: "ChemistryType",
table: "PowderCatalogItems");
migrationBuilder.DropColumn(
name: "CureCurvesJson",
table: "PowderCatalogItems");
migrationBuilder.DropColumn(
name: "CureScheduleText",
table: "PowderCatalogItems");
migrationBuilder.DropColumn(
name: "FormulationChanges",
table: "PowderCatalogItems");
migrationBuilder.DropColumn(
name: "MilThickness",
table: "PowderCatalogItems");
migrationBuilder.DropColumn(
name: "Source",
table: "PowderCatalogItems");
migrationBuilder.DropColumn(
name: "PowderCatalogItemId",
table: "InventoryItems");
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 1,
column: "CreatedAt",
value: new DateTime(2026, 6, 14, 1, 21, 46, 131, DateTimeKind.Utc).AddTicks(4191));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 2,
column: "CreatedAt",
value: new DateTime(2026, 6, 14, 1, 21, 46, 131, DateTimeKind.Utc).AddTicks(4196));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 3,
column: "CreatedAt",
value: new DateTime(2026, 6, 14, 1, 21, 46, 131, DateTimeKind.Utc).AddTicks(4197));
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,88 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace PowderCoating.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class SeedColumbiaSyncSettings : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
// Seed the SuperAdmin-managed platform settings for the Columbia Coatings catalog sync.
// Idempotent so it is safe against a DB where keys were added manually. The API key
// itself is NOT here — secrets live in configuration (Columbia:ApiKey), not this table.
migrationBuilder.Sql(@"
IF NOT EXISTS (SELECT 1 FROM PlatformSettings WHERE [Key] = 'ColumbiaSyncEnabled')
INSERT INTO PlatformSettings ([Key],[Value],[Label],[Description],[GroupName])
VALUES ('ColumbiaSyncEnabled','false','Columbia Coatings Sync Enabled','Master switch for the scheduled Columbia Coatings catalog sync. When off, no automatic or manual sync runs regardless of the configured API key.','Integrations');
IF NOT EXISTS (SELECT 1 FROM PlatformSettings WHERE [Key] = 'ColumbiaSyncIntervalDays')
INSERT INTO PlatformSettings ([Key],[Value],[Label],[Description],[GroupName])
VALUES ('ColumbiaSyncIntervalDays','7','Columbia Sync Interval (days)','How many days between automatic Columbia catalog syncs. A full sync is cheap (~25 API calls), so daily (1) or weekly (7) keeps pricing fresh.','Integrations');
IF NOT EXISTS (SELECT 1 FROM PlatformSettings WHERE [Key] = 'ColumbiaLastSyncedAt')
INSERT INTO PlatformSettings ([Key],[Value],[Label],[Description],[GroupName])
VALUES ('ColumbiaLastSyncedAt',NULL,'Columbia Last Synced At','Timestamp (UTC) of the last successful Columbia catalog sync. Set automatically by the sync job.','Integrations');
IF NOT EXISTS (SELECT 1 FROM PlatformSettings WHERE [Key] = 'ColumbiaLastSyncResult')
INSERT INTO PlatformSettings ([Key],[Value],[Label],[Description],[GroupName])
VALUES ('ColumbiaLastSyncResult',NULL,'Columbia Last Sync Result','Summary of the last Columbia catalog sync run (inserted/updated/discontinued counts or error). Set automatically by the sync job.','Integrations');
");
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 1,
column: "CreatedAt",
value: new DateTime(2026, 6, 17, 14, 44, 40, 147, DateTimeKind.Utc).AddTicks(5997));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 2,
column: "CreatedAt",
value: new DateTime(2026, 6, 17, 14, 44, 40, 147, DateTimeKind.Utc).AddTicks(6002));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 3,
column: "CreatedAt",
value: new DateTime(2026, 6, 17, 14, 44, 40, 147, DateTimeKind.Utc).AddTicks(6003));
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.Sql(@"
DELETE FROM PlatformSettings WHERE [Key] IN (
'ColumbiaSyncEnabled','ColumbiaSyncIntervalDays','ColumbiaLastSyncedAt','ColumbiaLastSyncResult'
);
");
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 1,
column: "CreatedAt",
value: new DateTime(2026, 6, 17, 14, 21, 27, 126, DateTimeKind.Utc).AddTicks(6644));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 2,
column: "CreatedAt",
value: new DateTime(2026, 6, 17, 14, 21, 27, 126, DateTimeKind.Utc).AddTicks(6651));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 3,
column: "CreatedAt",
value: new DateTime(2026, 6, 17, 14, 21, 27, 126, DateTimeKind.Utc).AddTicks(6652));
}
}
}
@@ -0,0 +1,82 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace PowderCoating.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class FilterInventorySkuUniqueIndexOnSoftDelete : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(
name: "IX_InventoryItems_CompanyId_SKU",
table: "InventoryItems");
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 1,
column: "CreatedAt",
value: new DateTime(2026, 6, 17, 16, 33, 6, 662, DateTimeKind.Utc).AddTicks(4251));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 2,
column: "CreatedAt",
value: new DateTime(2026, 6, 17, 16, 33, 6, 662, DateTimeKind.Utc).AddTicks(4258));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 3,
column: "CreatedAt",
value: new DateTime(2026, 6, 17, 16, 33, 6, 662, DateTimeKind.Utc).AddTicks(4259));
migrationBuilder.CreateIndex(
name: "IX_InventoryItems_CompanyId_SKU",
table: "InventoryItems",
columns: new[] { "CompanyId", "SKU" },
unique: true,
filter: "[IsDeleted] = 0");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(
name: "IX_InventoryItems_CompanyId_SKU",
table: "InventoryItems");
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 1,
column: "CreatedAt",
value: new DateTime(2026, 6, 17, 14, 44, 40, 147, DateTimeKind.Utc).AddTicks(5997));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 2,
column: "CreatedAt",
value: new DateTime(2026, 6, 17, 14, 44, 40, 147, DateTimeKind.Utc).AddTicks(6002));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 3,
column: "CreatedAt",
value: new DateTime(2026, 6, 17, 14, 44, 40, 147, DateTimeKind.Utc).AddTicks(6003));
migrationBuilder.CreateIndex(
name: "IX_InventoryItems_CompanyId_SKU",
table: "InventoryItems",
columns: new[] { "CompanyId", "SKU" },
unique: true);
}
}
}
@@ -0,0 +1,81 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace PowderCoating.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class AddInventoryCatalogReferencePrice : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<DateTime>(
name: "CatalogPriceUpdatedAt",
table: "InventoryItems",
type: "datetime2",
nullable: true);
migrationBuilder.AddColumn<decimal>(
name: "CatalogReferencePrice",
table: "InventoryItems",
type: "decimal(18,2)",
nullable: true);
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));
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "CatalogPriceUpdatedAt",
table: "InventoryItems");
migrationBuilder.DropColumn(
name: "CatalogReferencePrice",
table: "InventoryItems");
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 1,
column: "CreatedAt",
value: new DateTime(2026, 6, 17, 16, 33, 6, 662, DateTimeKind.Utc).AddTicks(4251));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 2,
column: "CreatedAt",
value: new DateTime(2026, 6, 17, 16, 33, 6, 662, DateTimeKind.Utc).AddTicks(4258));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 3,
column: "CreatedAt",
value: new DateTime(2026, 6, 17, 16, 33, 6, 662, DateTimeKind.Utc).AddTicks(4259));
}
}
}
@@ -0,0 +1,119 @@
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));
}
}
}
@@ -0,0 +1,123 @@
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));
}
}
}
@@ -0,0 +1,151 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace PowderCoating.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class AddCompanyDefaultGlAccounts : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<int>(
name: "DefaultCogsAccountId",
table: "CompanyPreferences",
type: "int",
nullable: true);
migrationBuilder.AddColumn<int>(
name: "DefaultInventoryAccountId",
table: "CompanyPreferences",
type: "int",
nullable: true);
migrationBuilder.AddColumn<int>(
name: "DefaultRevenueAccountId",
table: "CompanyPreferences",
type: "int",
nullable: true);
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 1,
column: "CreatedAt",
value: new DateTime(2026, 6, 20, 13, 49, 14, 564, DateTimeKind.Utc).AddTicks(4507));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 2,
column: "CreatedAt",
value: new DateTime(2026, 6, 20, 13, 49, 14, 564, DateTimeKind.Utc).AddTicks(4514));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 3,
column: "CreatedAt",
value: new DateTime(2026, 6, 20, 13, 49, 14, 564, DateTimeKind.Utc).AddTicks(4515));
migrationBuilder.CreateIndex(
name: "IX_CompanyPreferences_DefaultCogsAccountId",
table: "CompanyPreferences",
column: "DefaultCogsAccountId");
migrationBuilder.CreateIndex(
name: "IX_CompanyPreferences_DefaultInventoryAccountId",
table: "CompanyPreferences",
column: "DefaultInventoryAccountId");
migrationBuilder.CreateIndex(
name: "IX_CompanyPreferences_DefaultRevenueAccountId",
table: "CompanyPreferences",
column: "DefaultRevenueAccountId");
migrationBuilder.AddForeignKey(
name: "FK_CompanyPreferences_Accounts_DefaultCogsAccountId",
table: "CompanyPreferences",
column: "DefaultCogsAccountId",
principalTable: "Accounts",
principalColumn: "Id");
migrationBuilder.AddForeignKey(
name: "FK_CompanyPreferences_Accounts_DefaultInventoryAccountId",
table: "CompanyPreferences",
column: "DefaultInventoryAccountId",
principalTable: "Accounts",
principalColumn: "Id");
migrationBuilder.AddForeignKey(
name: "FK_CompanyPreferences_Accounts_DefaultRevenueAccountId",
table: "CompanyPreferences",
column: "DefaultRevenueAccountId",
principalTable: "Accounts",
principalColumn: "Id");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_CompanyPreferences_Accounts_DefaultCogsAccountId",
table: "CompanyPreferences");
migrationBuilder.DropForeignKey(
name: "FK_CompanyPreferences_Accounts_DefaultInventoryAccountId",
table: "CompanyPreferences");
migrationBuilder.DropForeignKey(
name: "FK_CompanyPreferences_Accounts_DefaultRevenueAccountId",
table: "CompanyPreferences");
migrationBuilder.DropIndex(
name: "IX_CompanyPreferences_DefaultCogsAccountId",
table: "CompanyPreferences");
migrationBuilder.DropIndex(
name: "IX_CompanyPreferences_DefaultInventoryAccountId",
table: "CompanyPreferences");
migrationBuilder.DropIndex(
name: "IX_CompanyPreferences_DefaultRevenueAccountId",
table: "CompanyPreferences");
migrationBuilder.DropColumn(
name: "DefaultCogsAccountId",
table: "CompanyPreferences");
migrationBuilder.DropColumn(
name: "DefaultInventoryAccountId",
table: "CompanyPreferences");
migrationBuilder.DropColumn(
name: "DefaultRevenueAccountId",
table: "CompanyPreferences");
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 1,
column: "CreatedAt",
value: new DateTime(2026, 6, 20, 0, 29, 46, 909, DateTimeKind.Utc).AddTicks(3976));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 2,
column: "CreatedAt",
value: new DateTime(2026, 6, 20, 0, 29, 46, 909, DateTimeKind.Utc).AddTicks(3981));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 3,
column: "CreatedAt",
value: new DateTime(2026, 6, 20, 0, 29, 46, 909, DateTimeKind.Utc).AddTicks(3982));
}
}
}
@@ -2185,6 +2185,9 @@ namespace PowderCoating.Infrastructure.Migrations
b.Property<string>("CreatedBy") b.Property<string>("CreatedBy")
.HasColumnType("nvarchar(max)"); .HasColumnType("nvarchar(max)");
b.Property<int?>("DefaultCogsAccountId")
.HasColumnType("int");
b.Property<string>("DefaultCurrency") b.Property<string>("DefaultCurrency")
.IsRequired() .IsRequired()
.HasColumnType("nvarchar(max)"); .HasColumnType("nvarchar(max)");
@@ -2193,6 +2196,9 @@ namespace PowderCoating.Infrastructure.Migrations
.IsRequired() .IsRequired()
.HasColumnType("nvarchar(max)"); .HasColumnType("nvarchar(max)");
b.Property<int?>("DefaultInventoryAccountId")
.HasColumnType("int");
b.Property<string>("DefaultJobPriority") b.Property<string>("DefaultJobPriority")
.IsRequired() .IsRequired()
.HasColumnType("nvarchar(max)"); .HasColumnType("nvarchar(max)");
@@ -2204,6 +2210,9 @@ namespace PowderCoating.Infrastructure.Migrations
b.Property<int>("DefaultQuoteValidityDays") b.Property<int>("DefaultQuoteValidityDays")
.HasColumnType("int"); .HasColumnType("int");
b.Property<int?>("DefaultRevenueAccountId")
.HasColumnType("int");
b.Property<string>("DefaultTimeFormat") b.Property<string>("DefaultTimeFormat")
.IsRequired() .IsRequired()
.HasColumnType("nvarchar(max)"); .HasColumnType("nvarchar(max)");
@@ -2380,6 +2389,12 @@ namespace PowderCoating.Infrastructure.Migrations
b.HasIndex("CompanyId") b.HasIndex("CompanyId")
.IsUnique(); .IsUnique();
b.HasIndex("DefaultCogsAccountId");
b.HasIndex("DefaultInventoryAccountId");
b.HasIndex("DefaultRevenueAccountId");
b.ToTable("CompanyPreferences"); b.ToTable("CompanyPreferences");
}); });
@@ -4075,6 +4090,12 @@ namespace PowderCoating.Infrastructure.Migrations
b.Property<decimal>("AverageCost") b.Property<decimal>("AverageCost")
.HasColumnType("decimal(18,2)"); .HasColumnType("decimal(18,2)");
b.Property<DateTime?>("CatalogPriceUpdatedAt")
.HasColumnType("datetime2");
b.Property<decimal?>("CatalogReferencePrice")
.HasColumnType("decimal(18,2)");
b.Property<string>("Category") b.Property<string>("Category")
.IsRequired() .IsRequired()
.HasColumnType("nvarchar(max)"); .HasColumnType("nvarchar(max)");
@@ -4173,6 +4194,9 @@ namespace PowderCoating.Infrastructure.Migrations
b.Property<string>("Notes") b.Property<string>("Notes")
.HasColumnType("nvarchar(max)"); .HasColumnType("nvarchar(max)");
b.Property<int?>("PowderCatalogItemId")
.HasColumnType("int");
b.Property<int?>("PrimaryVendorId") b.Property<int?>("PrimaryVendorId")
.HasColumnType("int"); .HasColumnType("int");
@@ -4241,7 +4265,8 @@ namespace PowderCoating.Infrastructure.Migrations
b.HasIndex("CompanyId", "SKU") b.HasIndex("CompanyId", "SKU")
.IsUnique() .IsUnique()
.HasDatabaseName("IX_InventoryItems_CompanyId_SKU"); .HasDatabaseName("IX_InventoryItems_CompanyId_SKU")
.HasFilter("[IsDeleted] = 0");
b.HasIndex("CompanyId", "QuantityOnHand", "ReorderPoint") b.HasIndex("CompanyId", "QuantityOnHand", "ReorderPoint")
.HasDatabaseName("IX_InventoryItems_CompanyId_Quantity_Reorder"); .HasDatabaseName("IX_InventoryItems_CompanyId_Quantity_Reorder");
@@ -6936,6 +6961,12 @@ namespace PowderCoating.Infrastructure.Migrations
b.Property<string>("ApplicationGuideUrl") b.Property<string>("ApplicationGuideUrl")
.HasColumnType("nvarchar(max)"); .HasColumnType("nvarchar(max)");
b.Property<string>("Category")
.HasColumnType("nvarchar(max)");
b.Property<string>("ChemistryType")
.HasColumnType("nvarchar(max)");
b.Property<string>("ColorFamilies") b.Property<string>("ColorFamilies")
.HasColumnType("nvarchar(max)"); .HasColumnType("nvarchar(max)");
@@ -6949,6 +6980,12 @@ namespace PowderCoating.Infrastructure.Migrations
b.Property<DateTime>("CreatedAt") b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime2"); .HasColumnType("datetime2");
b.Property<string>("CureCurvesJson")
.HasColumnType("nvarchar(max)");
b.Property<string>("CureScheduleText")
.HasColumnType("nvarchar(max)");
b.Property<decimal?>("CureTemperatureF") b.Property<decimal?>("CureTemperatureF")
.HasColumnType("decimal(18,2)"); .HasColumnType("decimal(18,2)");
@@ -6961,6 +6998,9 @@ namespace PowderCoating.Infrastructure.Migrations
b.Property<string>("Finish") b.Property<string>("Finish")
.HasColumnType("nvarchar(max)"); .HasColumnType("nvarchar(max)");
b.Property<string>("FormulationChanges")
.HasColumnType("nvarchar(max)");
b.Property<string>("ImageUrl") b.Property<string>("ImageUrl")
.HasColumnType("nvarchar(max)"); .HasColumnType("nvarchar(max)");
@@ -6973,6 +7013,9 @@ namespace PowderCoating.Infrastructure.Migrations
b.Property<DateTime?>("LastSyncedAt") b.Property<DateTime?>("LastSyncedAt")
.HasColumnType("datetime2"); .HasColumnType("datetime2");
b.Property<string>("MilThickness")
.HasColumnType("nvarchar(max)");
b.Property<string>("PriceTiersJson") b.Property<string>("PriceTiersJson")
.HasColumnType("nvarchar(max)"); .HasColumnType("nvarchar(max)");
@@ -6989,6 +7032,9 @@ namespace PowderCoating.Infrastructure.Migrations
.IsRequired() .IsRequired()
.HasColumnType("nvarchar(450)"); .HasColumnType("nvarchar(450)");
b.Property<string>("Source")
.HasColumnType("nvarchar(max)");
b.Property<decimal?>("SpecificGravity") b.Property<decimal?>("SpecificGravity")
.HasColumnType("decimal(18,2)"); .HasColumnType("decimal(18,2)");
@@ -7210,7 +7256,7 @@ namespace PowderCoating.Infrastructure.Migrations
{ {
Id = 1, Id = 1,
CompanyId = 0, CompanyId = 0,
CreatedAt = new DateTime(2026, 6, 14, 1, 21, 46, 131, DateTimeKind.Utc).AddTicks(4191), CreatedAt = new DateTime(2026, 6, 20, 13, 49, 14, 564, DateTimeKind.Utc).AddTicks(4507),
Description = "Standard pricing for regular customers", Description = "Standard pricing for regular customers",
DiscountPercent = 0m, DiscountPercent = 0m,
IsActive = true, IsActive = true,
@@ -7221,7 +7267,7 @@ namespace PowderCoating.Infrastructure.Migrations
{ {
Id = 2, Id = 2,
CompanyId = 0, CompanyId = 0,
CreatedAt = new DateTime(2026, 6, 14, 1, 21, 46, 131, DateTimeKind.Utc).AddTicks(4196), CreatedAt = new DateTime(2026, 6, 20, 13, 49, 14, 564, DateTimeKind.Utc).AddTicks(4514),
Description = "5% discount for preferred customers", Description = "5% discount for preferred customers",
DiscountPercent = 5m, DiscountPercent = 5m,
IsActive = true, IsActive = true,
@@ -7232,7 +7278,7 @@ namespace PowderCoating.Infrastructure.Migrations
{ {
Id = 3, Id = 3,
CompanyId = 0, CompanyId = 0,
CreatedAt = new DateTime(2026, 6, 14, 1, 21, 46, 131, DateTimeKind.Utc).AddTicks(4197), CreatedAt = new DateTime(2026, 6, 20, 13, 49, 14, 564, DateTimeKind.Utc).AddTicks(4515),
Description = "10% discount for premium customers", Description = "10% discount for premium customers",
DiscountPercent = 10m, DiscountPercent = 10m,
IsActive = true, IsActive = true,
@@ -9549,6 +9595,21 @@ namespace PowderCoating.Infrastructure.Migrations
.OnDelete(DeleteBehavior.Cascade) .OnDelete(DeleteBehavior.Cascade)
.IsRequired(); .IsRequired();
b.HasOne("PowderCoating.Core.Entities.Account", null)
.WithMany()
.HasForeignKey("DefaultCogsAccountId")
.OnDelete(DeleteBehavior.NoAction);
b.HasOne("PowderCoating.Core.Entities.Account", null)
.WithMany()
.HasForeignKey("DefaultInventoryAccountId")
.OnDelete(DeleteBehavior.NoAction);
b.HasOne("PowderCoating.Core.Entities.Account", null)
.WithMany()
.HasForeignKey("DefaultRevenueAccountId")
.OnDelete(DeleteBehavior.NoAction);
b.Navigation("Company"); b.Navigation("Company");
}); });
@@ -0,0 +1,299 @@
using System.Globalization;
using System.Net;
using System.Text.Json;
using System.Text.RegularExpressions;
using PowderCoating.Application.Constants;
using PowderCoating.Application.DTOs.Columbia;
using PowderCoating.Core.Entities;
namespace PowderCoating.Infrastructure.Services.Columbia;
/// <summary>
/// Maps a Columbia Coatings API product onto our platform <see cref="PowderCatalogItem"/>.
/// Pure, static, side-effect free so the tricky bits (manufacturer derivation, the free-text
/// cure-schedule parser, HTML stripping) can be unit tested directly against captured fixtures.
/// <para>
/// Columbia is a distributor reselling multiple brands, so <see cref="PowderCatalogItem.VendorName"/>
/// holds the DERIVED manufacturer (PPG / KP Pigments / Columbia) while
/// <see cref="PowderCatalogItem.Source"/> records the feed ("Columbia Coatings API") for
/// right-to-delete purges. The vendor's own categories/tags are read here only to derive the
/// manufacturer and additive flag — they are never stored raw.
/// </para>
/// </summary>
public static class ColumbiaCatalogMapper
{
/// <summary>A single parsed cure curve — hold <see cref="Minutes"/> at <see cref="TempF"/>.</summary>
public readonly record struct CureCurve(int TempF, int Minutes);
// Resin chemistries that mean "polyester + TGIC" but arrive formatted three different ways.
private static readonly Regex PolyesterTgic =
new(@"^\s*(polyester\s*[/ ]\s*tgic|tgic\s*[/ ]\s*polyester)\s*$", RegexOptions.IgnoreCase | RegexOptions.Compiled);
// "10 minutes @ 400°F", "7 minutes at 375 F", "Metal Temperature: 10 minutes at 400°F (204°C)".
// Degree glyph is optional and may be ° (U+00B0), ˚ (U+02DA), or º (U+00BA).
private static readonly Regex CureCurveRegex =
new(@"(\d+)\s*min(?:ute)?s?\.?\s*(?:@|at)\s*(\d{2,3})\s*[°˚º]?\s*F",
RegexOptions.IgnoreCase | RegexOptions.Compiled);
private static readonly Regex HtmlTag = new("<[^>]+>", RegexOptions.Compiled);
private static readonly Regex WhitespaceRun = new(@"\s{2,}", RegexOptions.Compiled);
private static readonly JsonSerializerOptions JsonOut = new() { WriteIndented = false };
/// <summary>
/// True for products that should not be in the powder catalog as standalone colors:
/// physical swatch cards (not powder at all), and tester (4 oz) / sample (5 lb) listings that
/// are just smaller SIZES of a parent powder that already exists as its own product. Detected
/// by specific SKU suffixes (-SW / -04) and unambiguous name markers ("SWATCH", "Tester",
/// "Sample ("). The sample-size "-S" SKU suffix is intentionally NOT used on its own — the
/// "Sample (" name marker catches every sample without risking a real SKU that ends in -S.
/// </summary>
public static bool IsExcludedProduct(ColumbiaProduct p)
{
var sku = p.Sku ?? string.Empty;
if (sku.EndsWith("-SW", StringComparison.OrdinalIgnoreCase)
|| sku.EndsWith("-04", StringComparison.OrdinalIgnoreCase))
return true;
var name = p.Name ?? string.Empty;
return name.Contains("SWATCH", StringComparison.OrdinalIgnoreCase)
|| name.Contains("Tester", StringComparison.OrdinalIgnoreCase)
|| name.Contains("Sample (", StringComparison.OrdinalIgnoreCase);
}
/// <summary>Maps a Columbia product into a fully populated (unsaved) catalog item.</summary>
public static PowderCatalogItem Map(ColumbiaProduct p)
{
var curves = ParseCureCurves(p.CureSchedule);
var primary = curves.Count > 0 ? curves[0] : (CureCurve?)null;
return new PowderCatalogItem
{
VendorName = DeriveManufacturer(p),
Sku = p.Sku.Trim(),
ColorName = p.Name.Trim(),
Source = ColumbiaIntegrationConstants.SourceName,
Category = IsAdditive(p) ? ColumbiaIntegrationConstants.CategoryPowderAdditives : null,
Description = StripHtml(p.Description),
UnitPrice = ParseBasePrice(p),
PriceTiersJson = BuildPriceTiersJson(p),
ImageUrl = NullIfBlank(p.FeaturedImage?.Src),
SdsUrl = NullIfBlank(p.SafetyDataSheet),
TdsUrl = NullIfBlank(p.TechnicalDataSheet),
ApplicationGuideUrl = NullIfBlank(FirstNonBlank(p.ProductFlyer, p.ProductBrochure)),
ProductUrl = NullIfBlank(p.Permalink),
ChemistryType = NormalizeChemistry(p.Type),
MilThickness = NullIfBlank(p.MilThickness),
CureScheduleText = NullIfBlank(p.CureSchedule),
CureCurvesJson = curves.Count > 0 ? JsonSerializer.Serialize(curves, JsonOut) : null,
CureTemperatureF = primary?.TempF,
CureTimeMinutes = primary?.Minutes,
RequiresClearCoat = DetectRequiresClearCoat(p),
ColorFamilies = BuildColorFamilies(p),
FormulationChanges = NullIfBlank(p.FormulationDateChanges),
// Coverage / specific gravity / transfer efficiency are not in the API — left null for
// lazy TDS/AI enrichment on first use. IsDiscontinued is handled by the sync sweep.
};
}
// ── Manufacturer derivation ───────────────────────────────────────────
/// <summary>
/// Derives the manufacturer from the product's taxonomy/SKU. Columbia resells PPG powders and
/// KP Pigments additives through the same feed; everything else is Columbia's own brand.
/// </summary>
public static string DeriveManufacturer(ColumbiaProduct p)
{
if (IsKpPigments(p))
return ColumbiaIntegrationConstants.ManufacturerKp;
if (IsPpg(p))
return ColumbiaIntegrationConstants.ManufacturerPpg;
return ColumbiaIntegrationConstants.ManufacturerColumbia;
}
private static bool IsKpPigments(ColumbiaProduct p) =>
p.Sku.StartsWith("ADD-", StringComparison.OrdinalIgnoreCase)
|| CategoryStartsWith(p, "KP");
private static bool IsPpg(ColumbiaProduct p) =>
CategoryStartsWith(p, "PPG");
/// <summary>
/// True for pigments/additives sold by weight (grams) rather than sprayed powders. These get
/// forced into the "Powder Additives" category. Keyed off the broad Additives category and the
/// ADD- SKU prefix, not just the KP brand (there are ~98 non-KP additives).
/// </summary>
public static bool IsAdditive(ColumbiaProduct p) =>
p.Sku.StartsWith("ADD-", StringComparison.OrdinalIgnoreCase)
|| p.Categories.Any(c => c.Name.Equals("Additives", StringComparison.OrdinalIgnoreCase))
|| CategoryStartsWith(p, "KP");
private static bool CategoryStartsWith(ColumbiaProduct p, string prefix) =>
p.Categories.Any(c => c.Name.StartsWith(prefix, StringComparison.OrdinalIgnoreCase));
// ── Pricing ───────────────────────────────────────────────────────────
/// <summary>
/// Base unit price = the top-level <c>price</c> (falling back to <c>regular_price</c>). For
/// variable products the parent <c>price</c> already carries the lead variant's price, while
/// <c>regular_price</c> is often "0", so price is preferred.
/// </summary>
public static decimal ParseBasePrice(ColumbiaProduct p)
{
if (TryParseMoney(p.Price, out var price) && price > 0)
return price;
if (TryParseMoney(p.RegularPrice, out var regular) && regular > 0)
return regular;
// Variable product with a zero parent price: fall back to the lowest variant price.
var variantPrices = (p.VariationPricing ?? new List<ColumbiaVariationPricing>())
.Select(v => TryParseMoney(v.Price, out var vp) ? vp : 0m)
.Where(v => v > 0)
.ToList();
return variantPrices.Count > 0 ? variantPrices.Min() : 0m;
}
/// <summary>
/// Captures quantity-break / variant pricing as JSON for later use. For variable products this
/// is the per-variant pricing (Bulk vs 1 lb Bags, gram sizes); for simple products it's the
/// tiered_pricing object. Null when neither is present.
/// </summary>
public static string? BuildPriceTiersJson(ColumbiaProduct p)
{
if (p.VariationPricing is { Count: > 0 })
return JsonSerializer.Serialize(p.VariationPricing, JsonOut);
if (p.TieredPricing is { ValueKind: JsonValueKind.Object } tiered)
{
// Only keep it if it actually carries tiers (avoid storing empty {type,...} shells).
if (tiered.TryGetProperty("tiers", out var tiers)
&& tiers.ValueKind == JsonValueKind.Array
&& tiers.GetArrayLength() > 0)
{
return tiered.GetRawText();
}
}
return null;
}
private static bool TryParseMoney(string? s, out decimal value) =>
decimal.TryParse(s, NumberStyles.Any, CultureInfo.InvariantCulture, out value);
// ── Cure schedule parsing ─────────────────────────────────────────────
/// <summary>
/// Extracts every "N minutes at/@ TTT°F" curve from a free-text cure schedule, in document
/// order. The first is treated as the primary/standard curve; the rest are alternate (often
/// lower-temperature) curves preserved for heat-sensitive substrates. Returns an empty list for
/// schedules with no parseable temp/time pair (partial-cure / clear-coat instructions).
/// </summary>
public static List<CureCurve> ParseCureCurves(string? cureSchedule)
{
var result = new List<CureCurve>();
if (string.IsNullOrWhiteSpace(cureSchedule))
return result;
foreach (Match m in CureCurveRegex.Matches(cureSchedule))
{
if (int.TryParse(m.Groups[1].Value, out var minutes)
&& int.TryParse(m.Groups[2].Value, out var tempF)
&& tempF is >= 150 and <= 600 // sanity: real cure temps
&& minutes is > 0 and <= 120)
{
var curve = new CureCurve(tempF, minutes);
if (!result.Contains(curve))
result.Add(curve);
}
}
return result;
}
// Powders that genuinely REQUIRE a clear coat say so explicitly. A casual "apply a clear coat
// for added durability" must NOT trip this — that over-flagged ~half the catalog and would pad
// quotes with unnecessary clear-coat steps.
private static readonly string[] RequiresClearPhrases =
{
"requires a clear", "requires clear", "require a clear",
"must be clear coated", "must be cleared", "needs a clear",
"clear coat is required", "clear coat required", "requires a clearcoat",
"requires a top coat", "clear coat to activate", "clear coat to achieve",
"requires a clear coat",
};
/// <summary>
/// Flags powders that genuinely need a clear coat: multi-step partial-cure (Illusion-style)
/// schedules, Columbia's named "Illusion" line, or explicit requirement phrasing. Casual
/// "you can clear coat this" mentions are intentionally ignored.
/// </summary>
public static bool DetectRequiresClearCoat(ColumbiaProduct p)
{
var cure = p.CureSchedule ?? string.Empty;
var name = p.Name ?? string.Empty;
// Partial-cure / multi-step instructions are the "apply this, then clear" case.
if (cure.Contains("partial cure", StringComparison.OrdinalIgnoreCase))
return true;
// Columbia's Illusion line needs a clear top coat to develop the effect.
if (name.Contains("Illusion", StringComparison.OrdinalIgnoreCase))
return true;
var text = $"{name} {cure} {p.Description}";
return RequiresClearPhrases.Any(phrase => text.Contains(phrase, StringComparison.OrdinalIgnoreCase));
}
// ── Misc field helpers ────────────────────────────────────────────────
/// <summary>Joins the color-group taxonomy ({name} entries) into a comma-separated families string.</summary>
public static string? BuildColorFamilies(ColumbiaProduct p)
{
var groups = p.PaColorGroup.Select(g => g.Name.Trim()).Where(n => n.Length > 0).Distinct().ToList();
if (groups.Count == 0)
{
// Fall back to the "Color Group" attribute options when the taxonomy is empty.
groups = p.Attributes
.Where(a => a.Name.Equals("Color Group", StringComparison.OrdinalIgnoreCase))
.SelectMany(a => a.Options.Select(o => o.Name.Trim()))
.Where(n => n.Length > 0)
.Distinct()
.ToList();
}
return groups.Count > 0 ? string.Join(",", groups) : null;
}
/// <summary>Normalizes resin chemistry — trims, and collapses the three Polyester/TGIC spellings.</summary>
public static string? NormalizeChemistry(string? type)
{
if (string.IsNullOrWhiteSpace(type))
return null;
var trimmed = type.Trim();
return PolyesterTgic.IsMatch(trimmed) ? "Polyester/TGIC" : trimmed;
}
/// <summary>Strips HTML tags/entities from a description and collapses whitespace to plain text.</summary>
public static string? StripHtml(string? html)
{
if (string.IsNullOrWhiteSpace(html))
return null;
var text = HtmlTag.Replace(html, " ");
text = WebUtility.HtmlDecode(text);
text = text.Replace("\r", " ").Replace("\n", " ").Replace("\t", " ");
text = WhitespaceRun.Replace(text, " ").Trim();
return text.Length > 0 ? text : null;
}
private static string? NullIfBlank(string? s) => string.IsNullOrWhiteSpace(s) ? null : s.Trim();
private static string? FirstNonBlank(params string?[] values) =>
values.FirstOrDefault(v => !string.IsNullOrWhiteSpace(v));
}
@@ -0,0 +1,187 @@
using System.Diagnostics;
using System.Globalization;
using Microsoft.Extensions.Logging;
using PowderCoating.Application.Constants;
using PowderCoating.Application.Interfaces;
using PowderCoating.Core.Entities;
using PowderCoating.Core.Interfaces;
namespace PowderCoating.Infrastructure.Services.Columbia;
/// <summary>
/// Full Columbia Coatings catalog sync: pages the API, maps each product, upserts via the shared
/// <see cref="IPowderCatalogUpsertService"/>, then reconciles discontinuations against the complete
/// pull. The discontinuation sweep runs ONLY after a successful full fetch — a partial pull (any
/// page failure throws from the client) aborts before the sweep so a transient error can never mass
/// flag the catalog as discontinued.
/// </summary>
public class ColumbiaCatalogSyncService : IColumbiaCatalogSyncService
{
private readonly IColumbiaCoatingsApiClient _client;
private readonly IPowderCatalogUpsertService _upsert;
private readonly IUnitOfWork _unitOfWork;
private readonly IPlatformSettingsService _settings;
private readonly ILogger<ColumbiaCatalogSyncService> _logger;
public ColumbiaCatalogSyncService(
IColumbiaCoatingsApiClient client,
IPowderCatalogUpsertService upsert,
IUnitOfWork unitOfWork,
IPlatformSettingsService settings,
ILogger<ColumbiaCatalogSyncService> logger)
{
_client = client;
_upsert = upsert;
_unitOfWork = unitOfWork;
_settings = settings;
_logger = logger;
}
/// <inheritdoc />
public async Task<ColumbiaSyncResult> RunSyncAsync(CancellationToken cancellationToken = default)
{
var result = new ColumbiaSyncResult { StartedAt = DateTime.UtcNow };
var stopwatch = Stopwatch.StartNew();
if (!_client.IsConfigured)
{
result.ErrorMessage = "Columbia API key is not configured.";
await RecordResultAsync(result);
return result;
}
try
{
// Full pull — throws on any page failure, which we treat as an incomplete sync.
var products = await _client.GetAllProductsAsync(cancellationToken);
result.TotalFetched = products.Count;
// Map and de-duplicate by (VendorName, SKU) in case the feed repeats a SKU.
// Exclude swatch cards and tester/sample size-variants — not standalone powder colors.
var mapped = products
.Where(p => !ColumbiaCatalogMapper.IsExcludedProduct(p))
.Select(ColumbiaCatalogMapper.Map)
.Where(m => !string.IsNullOrWhiteSpace(m.Sku))
.GroupBy(m => $"{m.VendorName}|{m.Sku}", StringComparer.OrdinalIgnoreCase)
.Select(g => g.First())
.ToList();
var upsertResult = await _upsert.UpsertAsync(mapped, result.StartedAt, cancellationToken);
result.Inserted = upsertResult.Inserted;
result.Updated = upsertResult.Updated;
result.Unchanged = upsertResult.Unchanged;
result.Skipped = upsertResult.Skipped;
// Remove any excluded records (swatches) that were synced before the exclusion existed,
// so they're deleted outright rather than lingering as "discontinued" powders.
await RemoveExcludedRecordsAsync();
// Complete pull succeeded — safe to reconcile discontinuations.
var incomingKeys = mapped
.Select(m => $"{m.VendorName}|{m.Sku}")
.ToHashSet(StringComparer.OrdinalIgnoreCase);
(result.Discontinued, result.Reactivated) =
await ReconcileDiscontinuationsAsync(incomingKeys, result.StartedAt);
result.Success = true;
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception ex)
{
_logger.LogError(ex, "Columbia catalog sync failed; skipping discontinuation sweep.");
result.Success = false;
result.ErrorMessage = ex.Message;
}
stopwatch.Stop();
result.Duration = stopwatch.Elapsed;
await RecordResultAsync(result);
return result;
}
/// <summary>
/// Flags catalog items sourced from Columbia that were NOT in this complete pull as discontinued,
/// and reactivates any previously-discontinued item that has reappeared. Returns (discontinued,
/// reactivated) counts.
/// </summary>
private async Task<(int Discontinued, int Reactivated)> ReconcileDiscontinuationsAsync(
HashSet<string> incomingKeys, DateTime runTimestamp)
{
var sourced = await _unitOfWork.PowderCatalog.FindAsync(
p => p.Source == ColumbiaIntegrationConstants.SourceName);
var discontinued = 0;
var reactivated = 0;
foreach (var item in sourced)
{
var present = incomingKeys.Contains($"{item.VendorName}|{item.Sku}");
if (!present && !item.IsDiscontinued)
{
item.IsDiscontinued = true;
item.UpdatedAt = runTimestamp;
await _unitOfWork.PowderCatalog.UpdateAsync(item);
discontinued++;
}
else if (present && item.IsDiscontinued)
{
item.IsDiscontinued = false;
item.UpdatedAt = runTimestamp;
await _unitOfWork.PowderCatalog.UpdateAsync(item);
reactivated++;
}
}
if (discontinued > 0 || reactivated > 0)
await _unitOfWork.CompleteAsync();
return (discontinued, reactivated);
}
/// <summary>
/// Deletes Columbia-sourced catalog rows that should not be in the catalog (swatch cards and
/// tester/sample size-variants). Mirrors <see cref="ColumbiaCatalogMapper.IsExcludedProduct"/>
/// on the stored columns. A no-op once the catalog is clean; guards against records synced
/// before the exclusion rule and ensures excluded items are removed, not flagged discontinued.
/// </summary>
private async Task RemoveExcludedRecordsAsync()
{
var excluded = (await _unitOfWork.PowderCatalog.FindAsync(p =>
p.Source == ColumbiaIntegrationConstants.SourceName
&& (p.Sku.EndsWith("-SW")
|| p.Sku.EndsWith("-04")
|| p.ColorName.Contains("SWATCH")
|| p.ColorName.Contains("Tester")
|| p.ColorName.Contains("Sample (")))).ToList();
if (excluded.Count == 0)
return;
foreach (var e in excluded)
await _unitOfWork.PowderCatalog.DeleteAsync(e);
await _unitOfWork.CompleteAsync();
_logger.LogInformation("Columbia sync: removed {Count} excluded record(s) (swatch/tester/sample) from the catalog.", excluded.Count);
}
/// <summary>Persists the run outcome to the last-synced / last-result platform settings.</summary>
private async Task RecordResultAsync(ColumbiaSyncResult result)
{
if (result.Success)
{
await _settings.SetAsync(
ColumbiaIntegrationConstants.SettingLastSyncedAt,
result.StartedAt.ToString("O", CultureInfo.InvariantCulture),
updatedBy: "Columbia Sync");
}
await _settings.SetAsync(
ColumbiaIntegrationConstants.SettingLastResult,
result.Summary,
updatedBy: "Columbia Sync");
}
}
@@ -0,0 +1,199 @@
using System.Net;
using System.Text.Json;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
using PowderCoating.Application.Constants;
using PowderCoating.Application.DTOs.Columbia;
using PowderCoating.Application.Interfaces;
namespace PowderCoating.Infrastructure.Services;
/// <summary>
/// HTTP client for the Columbia Coatings product catalog API. Reads the API key and base URL from
/// configuration (<c>Columbia:ApiKey</c> / <c>Columbia:BaseUrl</c>), sends the <c>X-API-Key</c>
/// header, and pages the catalog via <c>GET /products</c>. Honors the documented rate limit
/// (120 requests / 60s) by retrying on HTTP 429 after the <c>Retry-After</c> interval.
/// </summary>
public class ColumbiaCoatingsApiClient : IColumbiaCoatingsApiClient
{
private const int MaxRetriesPer429 = 5;
private const int DefaultRetryAfterSeconds = 5;
private const int MaxPagesSafetyCap = 1000; // guards against a server that never reports last page
private readonly IHttpClientFactory _httpClientFactory;
private readonly IConfiguration _config;
private readonly ILogger<ColumbiaCoatingsApiClient> _logger;
/// <summary>
/// Columbia returns snake_case JSON; the snake-case naming policy binds it to our PascalCase DTOs
/// without per-property attributes. Case-insensitive as a belt-and-braces fallback.
/// </summary>
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
PropertyNameCaseInsensitive = true,
Converters = { new ColumbiaImageJsonConverter() },
};
public ColumbiaCoatingsApiClient(
IHttpClientFactory httpClientFactory,
IConfiguration config,
ILogger<ColumbiaCoatingsApiClient> logger)
{
_httpClientFactory = httpClientFactory;
_config = config;
_logger = logger;
}
private string? ApiKey => _config[ColumbiaIntegrationConstants.ConfigApiKey];
private string BaseUrl =>
(_config[ColumbiaIntegrationConstants.ConfigBaseUrl] ?? ColumbiaIntegrationConstants.DefaultBaseUrl)
.TrimEnd('/');
private string ApiBasePath =>
(_config[ColumbiaIntegrationConstants.ConfigApiBasePath] ?? ColumbiaIntegrationConstants.DefaultApiBasePath)
.Trim('/');
/// <summary>Fully-qualified products endpoint: host + configurable API base path + /products.</summary>
private string ProductsUrl => $"{BaseUrl}/{ApiBasePath}{ColumbiaIntegrationConstants.ProductsResource}";
public bool IsConfigured => !string.IsNullOrWhiteSpace(ApiKey);
/// <inheritdoc />
public async Task<ColumbiaProductsResponse> GetProductsPageAsync(
int page, int perPage, CancellationToken cancellationToken = default)
{
EnsureConfigured();
perPage = Math.Clamp(perPage, 1, ColumbiaIntegrationConstants.MaxPerPage);
var url = $"{ProductsUrl}?page={page}&per_page={perPage}";
var json = await SendWithRetryAsync(url, $"page {page}", cancellationToken);
if (json == null)
return new ColumbiaProductsResponse();
return JsonSerializer.Deserialize<ColumbiaProductsResponse>(json, JsonOptions) ?? new ColumbiaProductsResponse();
}
/// <inheritdoc />
public async Task<ColumbiaProduct?> GetProductBySkuAsync(string sku, CancellationToken cancellationToken = default)
{
EnsureConfigured();
if (string.IsNullOrWhiteSpace(sku))
return null;
var url = $"{ProductsUrl}?sku={Uri.EscapeDataString(sku)}&per_page=1";
var json = await SendWithRetryAsync(url, $"sku {sku}", cancellationToken);
if (json == null)
return null;
var response = JsonSerializer.Deserialize<ColumbiaProductsResponse>(json, JsonOptions);
return response?.Items.FirstOrDefault();
}
/// <inheritdoc />
public async Task<ColumbiaProduct?> GetProductByIdAsync(int id, CancellationToken cancellationToken = default)
{
EnsureConfigured();
// The by-id endpoint returns a bare product object (not the {items,pagination} envelope).
var url = $"{ProductsUrl}/{id}";
var json = await SendWithRetryAsync(url, $"id {id}", cancellationToken);
if (json == null)
return null;
return JsonSerializer.Deserialize<ColumbiaProduct>(json, JsonOptions);
}
/// <inheritdoc />
public async Task<IReadOnlyList<ColumbiaProduct>> GetAllProductsAsync(
CancellationToken cancellationToken = default)
{
EnsureConfigured();
var all = new List<ColumbiaProduct>();
for (var page = 1; page <= MaxPagesSafetyCap; page++)
{
var response = await GetProductsPageAsync(page, ColumbiaIntegrationConstants.MaxPerPage, cancellationToken);
if (response.Items.Count == 0)
break;
all.AddRange(response.Items);
// Stop when the pagination block says we've reached the last page.
if (response.Pagination is { TotalPages: > 0 } p && page >= p.TotalPages)
break;
}
_logger.LogInformation("Columbia API: retrieved {Count} products across paged requests.", all.Count);
return all;
}
/// <summary>Throws when no API key is configured so callers fail fast rather than 401.</summary>
private void EnsureConfigured()
{
if (!IsConfigured)
throw new InvalidOperationException("Columbia Coatings API key is not configured (Columbia:ApiKey).");
}
/// <summary>
/// Issues a GET with the API key header and returns the response body. Retries on HTTP 429
/// (honoring Retry-After) up to <see cref="MaxRetriesPer429"/>. Returns null on 404 so
/// single-product lookups surface "not found" without throwing; throws on any other non-success.
/// <paramref name="describe"/> is a short label (e.g. "page 3", "sku ABC") for log/error context.
/// </summary>
private async Task<string?> SendWithRetryAsync(string url, string describe, CancellationToken cancellationToken)
{
for (var attempt = 1; ; attempt++)
{
using var request = new HttpRequestMessage(HttpMethod.Get, url);
request.Headers.Add("X-API-Key", ApiKey);
var client = _httpClientFactory.CreateClient();
using var response = await client.SendAsync(request, cancellationToken);
if (response.StatusCode == HttpStatusCode.TooManyRequests)
{
if (attempt > MaxRetriesPer429)
throw new HttpRequestException(
$"Columbia API still rate-limiting after {MaxRetriesPer429} retries ({describe}).");
var delaySeconds = GetRetryAfterSeconds(response) ?? DefaultRetryAfterSeconds;
_logger.LogWarning(
"Columbia API returned 429 ({Describe}, attempt {Attempt}); waiting {Delay}s before retry.",
describe, attempt, delaySeconds);
await Task.Delay(TimeSpan.FromSeconds(delaySeconds), cancellationToken);
continue;
}
if (response.StatusCode == HttpStatusCode.NotFound)
return null;
response.EnsureSuccessStatusCode();
return await response.Content.ReadAsStringAsync(cancellationToken);
}
}
/// <summary>
/// Parses the <c>Retry-After</c> header (delta-seconds or HTTP-date form) into whole seconds,
/// or null when absent/unparseable so the caller can fall back to a default.
/// </summary>
private static int? GetRetryAfterSeconds(HttpResponseMessage response)
{
var retryAfter = response.Headers.RetryAfter;
if (retryAfter == null)
return null;
if (retryAfter.Delta is { } delta)
return Math.Max(1, (int)Math.Ceiling(delta.TotalSeconds));
if (retryAfter.Date is { } date)
{
var seconds = (int)Math.Ceiling((date - DateTimeOffset.UtcNow).TotalSeconds);
return seconds > 0 ? seconds : 1;
}
return null;
}
}
@@ -2866,6 +2866,10 @@ public class CsvImportService : ICsvImportService
continue; 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; DateTime? openingBalanceDate = null;
if (!string.IsNullOrWhiteSpace(record.OpeningBalanceDate) if (!string.IsNullOrWhiteSpace(record.OpeningBalanceDate)
&& DateTime.TryParse(record.OpeningBalanceDate, out var parsedDate)) && DateTime.TryParse(record.OpeningBalanceDate, out var parsedDate))
@@ -3208,6 +3212,33 @@ 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() public byte[] GeneratePaymentTemplate()
{ {
using var memoryStream = new MemoryStream(); using var memoryStream = new MemoryStream();
@@ -3223,6 +3254,7 @@ public class CsvImportService : ICsvImportService
Amount = 250.00m, Amount = 250.00m,
PaymentDate = DateTime.Today, PaymentDate = DateTime.Today,
PaymentMethod = "Check", PaymentMethod = "Check",
DepositAccountNumber = "10100",
Reference = "CHK-1234", Reference = "CHK-1234",
Notes = "" Notes = ""
}); });
@@ -3232,6 +3264,651 @@ public class CsvImportService : ICsvImportService
return memoryStream.ToArray(); 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) public async Task<CsvImportResultDto> ImportPaymentsAsync(Stream csvStream, int companyId)
{ {
var result = new CsvImportResultDto(); var result = new CsvImportResultDto();
@@ -3256,6 +3933,14 @@ public class CsvImportService : ICsvImportService
var invoiceByNumber = invoices.Where(i => !string.IsNullOrEmpty(i.InvoiceNumber)) var invoiceByNumber = invoices.Where(i => !string.IsNullOrEmpty(i.InvoiceNumber))
.ToDictionary(i => i.InvoiceNumber.Trim(), i => i, StringComparer.OrdinalIgnoreCase); .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>() var validMethods = Enum.GetNames<PaymentMethod>()
.ToDictionary(n => n.ToLower(), n => Enum.Parse<PaymentMethod>(n), StringComparer.OrdinalIgnoreCase); .ToDictionary(n => n.ToLower(), n => Enum.Parse<PaymentMethod>(n), StringComparer.OrdinalIgnoreCase);
@@ -3301,6 +3986,18 @@ public class CsvImportService : ICsvImportService
method = PaymentMethod.Cash; 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 var payment = new Core.Entities.Payment
{ {
InvoiceId = invoice.Id, InvoiceId = invoice.Id,
@@ -3308,6 +4005,7 @@ public class CsvImportService : ICsvImportService
Amount = record.Amount, Amount = record.Amount,
PaymentDate = new DateTime(paymentDate.Year, paymentDate.Month, paymentDate.Day, 0, 0, 0, DateTimeKind.Utc), PaymentDate = new DateTime(paymentDate.Year, paymentDate.Month, paymentDate.Day, 0, 0, 0, DateTimeKind.Utc),
PaymentMethod = method, PaymentMethod = method,
DepositAccountId = depositAccountId,
Reference = string.IsNullOrWhiteSpace(record.Reference) ? null : record.Reference.Trim(), Reference = string.IsNullOrWhiteSpace(record.Reference) ? null : record.Reference.Trim(),
Notes = string.IsNullOrWhiteSpace(record.Notes) ? null : record.Notes.Trim() Notes = string.IsNullOrWhiteSpace(record.Notes) ? null : record.Notes.Trim()
}; };
@@ -1,6 +1,7 @@
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using PowderCoating.Application.DTOs.Accounting; using PowderCoating.Application.DTOs.Accounting;
using PowderCoating.Application.Interfaces; using PowderCoating.Application.Interfaces;
using PowderCoating.Core.Accounting;
using PowderCoating.Core.Entities; using PowderCoating.Core.Entities;
using PowderCoating.Core.Enums; using PowderCoating.Core.Enums;
using PowderCoating.Infrastructure.Data; using PowderCoating.Infrastructure.Data;
@@ -18,10 +19,65 @@ namespace PowderCoating.Infrastructure.Services;
public class FinancialReportService : IFinancialReportService public class FinancialReportService : IFinancialReportService
{ {
private readonly ApplicationDbContext _context; private readonly ApplicationDbContext _context;
private readonly ILedgerService _ledger;
public FinancialReportService(ApplicationDbContext context) public FinancialReportService(ApplicationDbContext context, ILedgerService ledger)
{ {
_context = 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/> /// <inheritdoc/>
@@ -33,7 +89,7 @@ public class FinancialReportService : IFinancialReportService
var isCash = accountingMethod == AccountingMethod.Cash; var isCash = accountingMethod == AccountingMethod.Cash;
var revenueAccounts = await _context.Accounts var revenueAccounts = await _context.Accounts
.Where(a => a.AccountType == AccountType.Revenue && a.IsActive) .Where(a => a.CompanyId == companyId && a.AccountType == AccountType.Revenue && a.IsActive)
.ToDictionaryAsync(a => a.Id); .ToDictionaryAsync(a => a.Id);
var revenueLines = new List<FinancialReportLine>(); var revenueLines = new List<FinancialReportLine>();
@@ -42,17 +98,26 @@ public class FinancialReportService : IFinancialReportService
{ {
// Cash basis: total payments received in period (not split by revenue account) // Cash basis: total payments received in period (not split by revenue account)
var cashRevenue = await _context.Payments var cashRevenue = await _context.Payments
.Where(p => p.PaymentDate >= from && p.PaymentDate <= toEnd .Where(p => p.CompanyId == companyId && p.PaymentDate >= from && p.PaymentDate <= toEnd
&& p.Invoice.Status != InvoiceStatus.Voided) && p.Invoice.Status != InvoiceStatus.Voided)
.SumAsync(p => (decimal?)p.Amount) ?? 0; .SumAsync(p => (decimal?)p.Amount) ?? 0;
if (cashRevenue > 0) if (cashRevenue > 0)
revenueLines.Add(new FinancialReportLine { AccountNumber = "—", AccountName = "Cash Receipts", Amount = cashRevenue }); 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 else
{ {
// Accrual basis: revenue = invoice item amounts by invoice date // Accrual basis: revenue = invoice item amounts by invoice date
var accrualRevenue = await _context.InvoiceItems var accrualRevenue = await _context.InvoiceItems
.Where(ii => ii.RevenueAccountId != null .Where(ii => ii.CompanyId == companyId
&& ii.RevenueAccountId != null
&& ii.Invoice.Status != InvoiceStatus.Draft && ii.Invoice.Status != InvoiceStatus.Draft
&& ii.Invoice.Status != InvoiceStatus.Voided && ii.Invoice.Status != InvoiceStatus.Voided
&& ii.Invoice.InvoiceDate >= from && ii.Invoice.InvoiceDate <= toEnd) && ii.Invoice.InvoiceDate >= from && ii.Invoice.InvoiceDate <= toEnd)
@@ -72,7 +137,8 @@ public class FinancialReportService : IFinancialReportService
.OrderBy(l => l.AccountNumber)); .OrderBy(l => l.AccountNumber));
var unlinkedRevenue = await _context.InvoiceItems var unlinkedRevenue = await _context.InvoiceItems
.Where(ii => ii.RevenueAccountId == null .Where(ii => ii.CompanyId == companyId
&& ii.RevenueAccountId == null
&& ii.Invoice.Status != InvoiceStatus.Draft && ii.Invoice.Status != InvoiceStatus.Draft
&& ii.Invoice.Status != InvoiceStatus.Voided && ii.Invoice.Status != InvoiceStatus.Voided
&& ii.Invoice.InvoiceDate >= from && ii.Invoice.InvoiceDate <= toEnd) && ii.Invoice.InvoiceDate >= from && ii.Invoice.InvoiceDate <= toEnd)
@@ -82,13 +148,19 @@ public class FinancialReportService : IFinancialReportService
// Contra-revenue: discounts granted and credit memos applied reduce gross revenue. // Contra-revenue: discounts granted and credit memos applied reduce gross revenue.
var periodDiscounts = await _context.Invoices var periodDiscounts = await _context.Invoices
.Where(i => i.Status != InvoiceStatus.Draft && i.Status != InvoiceStatus.Voided .Where(i => i.CompanyId == companyId && i.Status != InvoiceStatus.Draft && i.Status != InvoiceStatus.Voided
&& i.DiscountAmount > 0 && i.InvoiceDate >= from && i.InvoiceDate <= toEnd) && i.DiscountAmount > 0 && i.InvoiceDate >= from && i.InvoiceDate <= toEnd)
.SumAsync(i => (decimal?)i.DiscountAmount) ?? 0m; .SumAsync(i => (decimal?)i.DiscountAmount) ?? 0m;
var periodCredits = await _context.CreditMemoApplications // Credit-memo contra-revenue is recognized at issue (DR Sales Discounts). Net for the period =
.Where(a => a.AppliedDate >= from && a.AppliedDate <= toEnd // memos issued in the period minus the unapplied remainder of memos voided in the period.
&& a.Invoice.Status != InvoiceStatus.Voided) var periodCmIssued = await _context.CreditMemos
.SumAsync(a => (decimal?)a.AmountApplied) ?? 0m; .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 totalDeductions = periodDiscounts + periodCredits; var totalDeductions = periodDiscounts + periodCredits;
if (totalDeductions > 0) if (totalDeductions > 0)
revenueLines.Add(new FinancialReportLine revenueLines.Add(new FinancialReportLine
@@ -98,9 +170,26 @@ public class FinancialReportService : IFinancialReportService
Amount = -totalDeductions 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. // GC sales are deferred to GC Liability at issuance; revenue is recognized on redemption.
var periodGcReclassified = await _context.InvoiceItems var periodGcReclassified = await _context.InvoiceItems
.Where(ii => ii.IsGiftCertificate .Where(ii => ii.CompanyId == companyId
&& ii.IsGiftCertificate
&& ii.Invoice.Status != InvoiceStatus.Draft && ii.Invoice.Status != InvoiceStatus.Draft
&& ii.Invoice.Status != InvoiceStatus.Voided && ii.Invoice.Status != InvoiceStatus.Voided
&& ii.Invoice.InvoiceDate >= from && ii.Invoice.InvoiceDate <= toEnd) && ii.Invoice.InvoiceDate >= from && ii.Invoice.InvoiceDate <= toEnd)
@@ -115,7 +204,7 @@ public class FinancialReportService : IFinancialReportService
// Voided GCs with remaining balance are breakage income (liability extinguished). // Voided GCs with remaining balance are breakage income (liability extinguished).
var periodGcBreakage = await _context.GiftCertificates var periodGcBreakage = await _context.GiftCertificates
.Where(gc => !gc.IsDeleted && gc.Status == GiftCertificateStatus.Voided .Where(gc => gc.CompanyId == companyId && !gc.IsDeleted && gc.Status == GiftCertificateStatus.Voided
&& gc.UpdatedAt >= from && gc.UpdatedAt <= toEnd && gc.UpdatedAt >= from && gc.UpdatedAt <= toEnd
&& gc.OriginalAmount > gc.RedeemedAmount) && gc.OriginalAmount > gc.RedeemedAmount)
.SumAsync(gc => (decimal?)(gc.OriginalAmount - gc.RedeemedAmount)) ?? 0m; .SumAsync(gc => (decimal?)(gc.OriginalAmount - gc.RedeemedAmount)) ?? 0m;
@@ -134,7 +223,7 @@ public class FinancialReportService : IFinancialReportService
if (isCash) if (isCash)
{ {
var cashExpenses = await _context.Expenses var cashExpenses = await _context.Expenses
.Where(e => e.Date >= from && e.Date <= toEnd) .Where(e => e.CompanyId == companyId && e.Date >= from && e.Date <= toEnd)
.GroupBy(e => e.ExpenseAccountId) .GroupBy(e => e.ExpenseAccountId)
.Select(g => new { AccountId = g.Key, Amount = g.Sum(e => e.Amount) }) .Select(g => new { AccountId = g.Key, Amount = g.Sum(e => e.Amount) })
.ToListAsync(); .ToListAsync();
@@ -143,7 +232,7 @@ public class FinancialReportService : IFinancialReportService
// Pro-rate paid bill line items by payment fraction (bill total may be partial) // Pro-rate paid bill line items by payment fraction (bill total may be partial)
var paidBillLines = await _context.BillPayments var paidBillLines = await _context.BillPayments
.Where(bp => bp.PaymentDate >= from && bp.PaymentDate <= toEnd) .Where(bp => bp.CompanyId == companyId && bp.PaymentDate >= from && bp.PaymentDate <= toEnd)
.Include(bp => bp.Bill).ThenInclude(b => b.LineItems) .Include(bp => bp.Bill).ThenInclude(b => b.LineItems)
.ToListAsync(); .ToListAsync();
foreach (var bp in paidBillLines) foreach (var bp in paidBillLines)
@@ -156,7 +245,7 @@ public class FinancialReportService : IFinancialReportService
else else
{ {
var accrualExpenses = await _context.Expenses var accrualExpenses = await _context.Expenses
.Where(e => e.Date >= from && e.Date <= toEnd) .Where(e => e.CompanyId == companyId && e.Date >= from && e.Date <= toEnd)
.GroupBy(e => e.ExpenseAccountId) .GroupBy(e => e.ExpenseAccountId)
.Select(g => new { AccountId = g.Key, Amount = g.Sum(e => e.Amount) }) .Select(g => new { AccountId = g.Key, Amount = g.Sum(e => e.Amount) })
.ToListAsync(); .ToListAsync();
@@ -164,7 +253,8 @@ public class FinancialReportService : IFinancialReportService
expenseAmounts[e.AccountId] = expenseAmounts.GetValueOrDefault(e.AccountId) + e.Amount; expenseAmounts[e.AccountId] = expenseAmounts.GetValueOrDefault(e.AccountId) + e.Amount;
var accrualBillLines = await _context.BillLineItems var accrualBillLines = await _context.BillLineItems
.Where(bli => bli.AccountId != null .Where(bli => bli.CompanyId == companyId
&& bli.AccountId != null
&& bli.Bill.Status != BillStatus.Draft && bli.Bill.Status != BillStatus.Draft
&& bli.Bill.Status != BillStatus.Voided && bli.Bill.Status != BillStatus.Voided
&& bli.Bill.BillDate >= from && bli.Bill.BillDate <= toEnd) && bli.Bill.BillDate >= from && bli.Bill.BillDate <= toEnd)
@@ -173,10 +263,23 @@ public class FinancialReportService : IFinancialReportService
.ToListAsync(); .ToListAsync();
foreach (var b in accrualBillLines) foreach (var b in accrualBillLines)
expenseAmounts[b.AccountId] = expenseAmounts.GetValueOrDefault(b.AccountId) + b.Amount; 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 var expAccounts = await _context.Accounts
.Where(a => (a.AccountType == AccountType.Expense || a.AccountType == AccountType.CostOfGoods) && a.IsActive) .Where(a => a.CompanyId == companyId && (a.AccountType == AccountType.Expense || a.AccountType == AccountType.CostOfGoods) && a.IsActive)
.ToDictionaryAsync(a => a.Id); .ToDictionaryAsync(a => a.Id);
var cogsLines = new List<FinancialReportLine>(); var cogsLines = new List<FinancialReportLine>();
@@ -216,46 +319,45 @@ public class FinancialReportService : IFinancialReportService
// Pre-compute balance contributions per account (batch GROUP BY queries avoid N+1) // Pre-compute balance contributions per account (batch GROUP BY queries avoid N+1)
var depositsByAcct = await _context.Payments var depositsByAcct = await _context.Payments
.Where(p => p.PaymentDate <= asOfEnd && p.DepositAccountId != null .Where(p => p.CompanyId == companyId && p.PaymentDate <= asOfEnd && p.DepositAccountId != null
&& p.Invoice.Status != InvoiceStatus.Voided && p.Invoice.Status != InvoiceStatus.Voided)
&& p.Invoice.Status != InvoiceStatus.WrittenOff)
.GroupBy(p => p.DepositAccountId!.Value) .GroupBy(p => p.DepositAccountId!.Value)
.Select(g => new { Id = g.Key, Amount = g.Sum(p => p.Amount) }) .Select(g => new { Id = g.Key, Amount = g.Sum(p => p.Amount) })
.ToDictionaryAsync(g => g.Id, g => g.Amount); .ToDictionaryAsync(g => g.Id, g => g.Amount);
var expFromByAcct = await _context.Expenses var expFromByAcct = await _context.Expenses
.Where(e => e.Date <= asOfEnd) .Where(e => e.CompanyId == companyId && e.Date <= asOfEnd)
.GroupBy(e => e.PaymentAccountId) .GroupBy(e => e.PaymentAccountId)
.Select(g => new { Id = g.Key, Amount = g.Sum(e => e.Amount) }) .Select(g => new { Id = g.Key, Amount = g.Sum(e => e.Amount) })
.ToDictionaryAsync(g => g.Id, g => g.Amount); .ToDictionaryAsync(g => g.Id, g => g.Amount);
var bpFromByAcct = await _context.BillPayments var bpFromByAcct = await _context.BillPayments
.Where(bp => bp.PaymentDate <= asOfEnd) .Where(bp => bp.CompanyId == companyId && bp.PaymentDate <= asOfEnd)
.GroupBy(bp => bp.BankAccountId) .GroupBy(bp => bp.BankAccountId)
.Select(g => new { Id = g.Key, Amount = g.Sum(bp => bp.Amount) }) .Select(g => new { Id = g.Key, Amount = g.Sum(bp => bp.Amount) })
.ToDictionaryAsync(g => g.Id, g => g.Amount); .ToDictionaryAsync(g => g.Id, g => g.Amount);
var billsByApAcct = await _context.Bills var billsByApAcct = await _context.Bills
.Where(b => b.Status != BillStatus.Draft && b.Status != BillStatus.Voided && b.BillDate <= asOfEnd) .Where(b => b.CompanyId == companyId && b.Status != BillStatus.Draft && b.Status != BillStatus.Voided && b.BillDate <= asOfEnd)
.GroupBy(b => b.APAccountId) .GroupBy(b => b.APAccountId)
.Select(g => new { Id = g.Key, Amount = g.Sum(b => b.Total) }) .Select(g => new { Id = g.Key, Amount = g.Sum(b => b.Total) })
.ToDictionaryAsync(g => g.Id, g => g.Amount); .ToDictionaryAsync(g => g.Id, g => g.Amount);
var bpByApAcct = await _context.BillPayments var bpByApAcct = await _context.BillPayments
.Where(bp => bp.PaymentDate <= asOfEnd) .Where(bp => bp.CompanyId == companyId && bp.PaymentDate <= asOfEnd)
.GroupBy(bp => bp.Bill.APAccountId) .GroupBy(bp => bp.Bill.APAccountId)
.Select(g => new { Id = g.Key, Amount = g.Sum(bp => bp.Amount) }) .Select(g => new { Id = g.Key, Amount = g.Sum(bp => bp.Amount) })
.ToDictionaryAsync(g => g.Id, g => g.Amount); .ToDictionaryAsync(g => g.Id, g => g.Amount);
// AP: vendor credit applications reduce AP (DR side) when matched against specific bills. // AP: vendor credit applications reduce AP (DR side) when matched against specific bills.
var vcByApAcctBs = await _context.VendorCreditApplications var vcByApAcctBs = await _context.VendorCreditApplications
.Where(vca => vca.AppliedDate <= asOfEnd) .Where(vca => vca.CompanyId == companyId && vca.AppliedDate <= asOfEnd)
.GroupBy(vca => vca.VendorCredit.APAccountId) .GroupBy(vca => vca.VendorCredit.APAccountId)
.Select(g => new { Id = g.Key, Amount = g.Sum(vca => vca.Amount) }) .Select(g => new { Id = g.Key, Amount = g.Sum(vca => vca.Amount) })
.ToDictionaryAsync(g => g.Id, g => g.Amount); .ToDictionaryAsync(g => g.Id, g => g.Amount);
var taxByAcct = await _context.Invoices var taxByAcct = await _context.Invoices
.Where(i => i.SalesTaxAccountId != null && i.TaxAmount > 0 .Where(i => i.CompanyId == companyId && i.SalesTaxAccountId != null && i.TaxAmount > 0
&& i.Status != InvoiceStatus.Draft && i.Status != InvoiceStatus.Voided && i.Status != InvoiceStatus.Draft && i.Status != InvoiceStatus.Voided
&& i.InvoiceDate <= asOfEnd) && i.InvoiceDate <= asOfEnd)
.GroupBy(i => i.SalesTaxAccountId!.Value) .GroupBy(i => i.SalesTaxAccountId!.Value)
@@ -263,32 +365,67 @@ public class FinancialReportService : IFinancialReportService
.ToDictionaryAsync(g => g.Id, g => g.Amount); .ToDictionaryAsync(g => g.Id, g => g.Amount);
var arDebits = await _context.Invoices var arDebits = await _context.Invoices
.Where(i => i.Status != InvoiceStatus.Draft && i.Status != InvoiceStatus.Voided && i.InvoiceDate <= asOfEnd) .Where(i => i.CompanyId == companyId && i.Status != InvoiceStatus.Draft && i.Status != InvoiceStatus.Voided && i.InvoiceDate <= asOfEnd)
.SumAsync(i => (decimal?)i.Total) ?? 0; .SumAsync(i => (decimal?)i.Total) ?? 0;
var arCredits = await _context.Payments var arCredits = await _context.Payments
.Where(p => p.PaymentDate <= asOfEnd .Where(p => p.CompanyId == companyId && p.PaymentDate <= asOfEnd
&& p.Invoice.Status != InvoiceStatus.Voided && p.Invoice.Status != InvoiceStatus.Voided)
&& p.Invoice.Status != InvoiceStatus.WrittenOff)
.SumAsync(p => (decimal?)p.Amount) ?? 0; .SumAsync(p => (decimal?)p.Amount) ?? 0;
// Credit memo applications reduce open AR (CR AR when a credit is applied to an invoice). // Credit memo applications reduce open AR (CR AR when a credit is applied to an invoice).
arCredits += await _context.CreditMemoApplications var cmAppliedBs = await _context.CreditMemoApplications
.Where(a => a.AppliedDate <= asOfEnd && a.Invoice.Status != InvoiceStatus.Voided) .Where(a => a.CompanyId == companyId && a.AppliedDate <= asOfEnd && a.Invoice.Status != InvoiceStatus.Voided)
.SumAsync(a => (decimal?)a.AmountApplied) ?? 0; .SumAsync(a => (decimal?)a.AmountApplied) ?? 0;
// Refunds reverse collected payments — they re-open AR so reduce net AR credits. arCredits += cmAppliedBs;
arCredits -= await _context.Refunds // Gift-certificate redemptions also credit AR (ApplyGiftCertificate posts DR 2500 / CR AR).
.Where(r => r.RefundDate <= asOfEnd && !r.IsDeleted) // Mirror the posting here so AR is not overstated and the entry's two sides stay balanced.
.SumAsync(r => (decimal?)r.Amount) ?? 0m; 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 by bank account: money that left the account (CR to checking/bank). // Refunds by bank account: money that left the account (CR to checking/bank).
var refundsByAcctBs = await _context.Refunds var refundsByAcctBs = await _context.Refunds
.Where(r => r.RefundDate <= asOfEnd && !r.IsDeleted && r.DepositAccountId != null) .Where(r => r.CompanyId == companyId && r.RefundDate <= asOfEnd && !r.IsDeleted && r.DepositAccountId != null)
.GroupBy(r => r.DepositAccountId!.Value) .GroupBy(r => r.DepositAccountId!.Value)
.Select(g => new { Id = g.Key, Amount = g.Sum(r => r.Amount) }) .Select(g => new { Id = g.Key, Amount = g.Sum(r => r.Amount) })
.ToDictionaryAsync(g => g.Id, g => g.Amount); .ToDictionaryAsync(g => g.Id, g => g.Amount);
// Deposits by bank account: cash received at deposit recording time (DR bank). // Deposits by bank account: cash received at deposit recording time (DR bank).
var depositsByAcctDepBs = await _context.Deposits var depositsByAcctDepBs = await _context.Deposits
.Where(d => !d.IsDeleted && d.DepositAccountId != null && d.ReceivedDate <= asOfEnd) .Where(d => d.CompanyId == companyId && !d.IsDeleted && d.DepositAccountId != null && d.ReceivedDate <= asOfEnd)
.GroupBy(d => d.DepositAccountId!.Value) .GroupBy(d => d.DepositAccountId!.Value)
.Select(g => new { Id = g.Key, Amount = g.Sum(d => d.Amount) }) .Select(g => new { Id = g.Key, Amount = g.Sum(d => d.Amount) })
.ToDictionaryAsync(g => g.Id, g => g.Amount); .ToDictionaryAsync(g => g.Id, g => g.Amount);
@@ -299,11 +436,11 @@ public class FinancialReportService : IFinancialReportService
.Select(a => (int?)a.Id).FirstOrDefaultAsync(); .Select(a => (int?)a.Id).FirstOrDefaultAsync();
var custDepositsCreditsBs = custDepositsAcctIdBs.HasValue var custDepositsCreditsBs = custDepositsAcctIdBs.HasValue
? (await _context.Deposits ? (await _context.Deposits
.Where(d => !d.IsDeleted && d.ReceivedDate <= asOfEnd) .Where(d => d.CompanyId == companyId && !d.IsDeleted && d.ReceivedDate <= asOfEnd)
.SumAsync(d => (decimal?)d.Amount) ?? 0m) : 0m; .SumAsync(d => (decimal?)d.Amount) ?? 0m) : 0m;
var custDepositsDebitsBs = custDepositsAcctIdBs.HasValue var custDepositsDebitsBs = custDepositsAcctIdBs.HasValue
? (await _context.Deposits ? (await _context.Deposits
.Where(d => !d.IsDeleted && d.AppliedToInvoiceId != null && d.AppliedDate <= asOfEnd) .Where(d => d.CompanyId == companyId && !d.IsDeleted && d.AppliedToInvoiceId != null && d.AppliedDate <= asOfEnd)
.SumAsync(d => (decimal?)d.Amount) ?? 0m) : 0m; .SumAsync(d => (decimal?)d.Amount) ?? 0m) : 0m;
// Gift Certificate Liability (2500): balance driven by GC issuances, redemptions, and voids. // Gift Certificate Liability (2500): balance driven by GC issuances, redemptions, and voids.
@@ -312,14 +449,14 @@ public class FinancialReportService : IFinancialReportService
.Select(a => (int?)a.Id).FirstOrDefaultAsync(); .Select(a => (int?)a.Id).FirstOrDefaultAsync();
var gcLiabilityCreditsBs = gcLiabilityAcctIdBs.HasValue var gcLiabilityCreditsBs = gcLiabilityAcctIdBs.HasValue
? (await _context.GiftCertificates ? (await _context.GiftCertificates
.Where(gc => !gc.IsDeleted && gc.IssueDate <= asOfEnd) .Where(gc => gc.CompanyId == companyId && !gc.IsDeleted && gc.IssueDate <= asOfEnd)
.SumAsync(gc => (decimal?)gc.OriginalAmount) ?? 0m) : 0m; .SumAsync(gc => (decimal?)gc.OriginalAmount) ?? 0m) : 0m;
var gcLiabilityDebitsBs = gcLiabilityAcctIdBs.HasValue var gcLiabilityDebitsBs = gcLiabilityAcctIdBs.HasValue
? ((await _context.GiftCertificateRedemptions ? ((await _context.GiftCertificateRedemptions
.Where(r => !r.IsDeleted && r.RedeemedDate <= asOfEnd) .Where(r => r.CompanyId == companyId && !r.IsDeleted && r.RedeemedDate <= asOfEnd)
.SumAsync(r => (decimal?)r.AmountRedeemed) ?? 0m) .SumAsync(r => (decimal?)r.AmountRedeemed) ?? 0m)
+ (await _context.GiftCertificates + (await _context.GiftCertificates
.Where(gc => !gc.IsDeleted && gc.Status == GiftCertificateStatus.Voided .Where(gc => gc.CompanyId == companyId && !gc.IsDeleted && gc.Status == GiftCertificateStatus.Voided
&& gc.UpdatedAt <= asOfEnd && gc.OriginalAmount > gc.RedeemedAmount) && gc.UpdatedAt <= asOfEnd && gc.OriginalAmount > gc.RedeemedAmount)
.SumAsync(gc => (decimal?)(gc.OriginalAmount - gc.RedeemedAmount)) ?? 0m)) : 0m; .SumAsync(gc => (decimal?)(gc.OriginalAmount - gc.RedeemedAmount)) ?? 0m)) : 0m;
@@ -328,23 +465,21 @@ public class FinancialReportService : IFinancialReportService
// plus (5) the net effect of any posted journal entries on revenue/expense/COGS accounts // 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). // (accruals, depreciation, year-end closes, and other adjustments not in the tables above).
var lifetimeRevenue = await _context.InvoiceItems var lifetimeRevenue = await _context.InvoiceItems
.Where(ii => ii.Invoice.Status != InvoiceStatus.Draft && ii.Invoice.Status != InvoiceStatus.Voided && ii.Invoice.InvoiceDate <= asOfEnd) .Where(ii => ii.CompanyId == companyId && ii.Invoice.Status != InvoiceStatus.Draft && ii.Invoice.Status != InvoiceStatus.Voided && ii.Invoice.InvoiceDate <= asOfEnd)
.SumAsync(ii => (decimal?)ii.TotalPrice) ?? 0; .SumAsync(ii => (decimal?)ii.TotalPrice) ?? 0;
var lifetimeDiscounts = isCash ? 0m var lifetimeDiscounts = isCash ? 0m
: (await _context.Invoices : (await _context.Invoices
.Where(i => i.Status != InvoiceStatus.Draft && i.Status != InvoiceStatus.Voided .Where(i => i.CompanyId == companyId && i.Status != InvoiceStatus.Draft && i.Status != InvoiceStatus.Voided
&& i.DiscountAmount > 0 && i.InvoiceDate <= asOfEnd) && i.DiscountAmount > 0 && i.InvoiceDate <= asOfEnd)
.SumAsync(i => (decimal?)i.DiscountAmount) ?? 0m); .SumAsync(i => (decimal?)i.DiscountAmount) ?? 0m);
// Credit memos applied to invoices reduce net revenue (contra-revenue, same as discounts). // Credit memos are contra-revenue recognized at issue (DR Sales Discounts). Net revenue is
var lifetimeCreditMemos = isCash ? 0m // reduced by the issued amount (active memos in full + applied portion of voided memos).
: (await _context.CreditMemoApplications var lifetimeCreditMemos = isCash ? 0m : cmContraRevenueBs;
.Where(a => a.AppliedDate <= asOfEnd && a.Invoice.Status != InvoiceStatus.Voided)
.SumAsync(a => (decimal?)a.AmountApplied) ?? 0m);
var lifetimeDirectExp = await _context.Expenses var lifetimeDirectExp = await _context.Expenses
.Where(e => e.Date <= asOfEnd) .Where(e => e.CompanyId == companyId && e.Date <= asOfEnd)
.SumAsync(e => (decimal?)e.Amount) ?? 0; .SumAsync(e => (decimal?)e.Amount) ?? 0;
var lifetimeBillCosts = await _context.BillLineItems var lifetimeBillCosts = await _context.BillLineItems
.Where(bli => bli.Bill.Status != BillStatus.Draft && bli.Bill.Status != BillStatus.Voided && bli.Bill.BillDate <= asOfEnd) .Where(bli => bli.CompanyId == companyId && bli.Bill.Status != BillStatus.Draft && bli.Bill.Status != BillStatus.Voided && bli.Bill.BillDate <= asOfEnd)
.SumAsync(bli => (decimal?)bli.Amount) ?? 0; .SumAsync(bli => (decimal?)bli.Amount) ?? 0;
// JE net effect on revenue accounts (positive = additional revenue recognised via manual JE) // JE net effect on revenue accounts (positive = additional revenue recognised via manual JE)
@@ -376,20 +511,21 @@ public class FinancialReportService : IFinancialReportService
// GC items sold via invoices are reclassified to GC Liability and not yet earned income. // GC items sold via invoices are reclassified to GC Liability and not yet earned income.
var lifetimeGcReclassified = await _context.InvoiceItems var lifetimeGcReclassified = await _context.InvoiceItems
.Where(ii => ii.IsGiftCertificate .Where(ii => ii.CompanyId == companyId && ii.IsGiftCertificate
&& ii.Invoice.Status != InvoiceStatus.Draft && ii.Invoice.Status != InvoiceStatus.Draft
&& ii.Invoice.Status != InvoiceStatus.Voided && ii.Invoice.Status != InvoiceStatus.Voided
&& ii.Invoice.InvoiceDate <= asOfEnd) && ii.Invoice.InvoiceDate <= asOfEnd)
.SumAsync(ii => (decimal?)ii.TotalPrice) ?? 0m; .SumAsync(ii => (decimal?)ii.TotalPrice) ?? 0m;
// Voided GCs with remaining balance become breakage income (the liability is extinguished). // Voided GCs with remaining balance become breakage income (the liability is extinguished).
var lifetimeGcBreakage = await _context.GiftCertificates var lifetimeGcBreakage = await _context.GiftCertificates
.Where(gc => !gc.IsDeleted && gc.Status == GiftCertificateStatus.Voided .Where(gc => gc.CompanyId == companyId && !gc.IsDeleted && gc.Status == GiftCertificateStatus.Voided
&& gc.UpdatedAt <= asOfEnd && gc.OriginalAmount > gc.RedeemedAmount) && gc.UpdatedAt <= asOfEnd && gc.OriginalAmount > gc.RedeemedAmount)
.SumAsync(gc => (decimal?)(gc.OriginalAmount - gc.RedeemedAmount)) ?? 0m; .SumAsync(gc => (decimal?)(gc.OriginalAmount - gc.RedeemedAmount)) ?? 0m;
var retainedEarnings = lifetimeRevenue + jeRevNet var retainedEarnings = lifetimeRevenue + jeRevNet
- lifetimeDiscounts - lifetimeDiscounts
- lifetimeCreditMemos - lifetimeCreditMemos
- refundReturnsTotalBs // revenue portion of cash refunds (reversed sales)
- lifetimeGcReclassified // deferred to GC Liability, not earned yet - lifetimeGcReclassified // deferred to GC Liability, not earned yet
+ lifetimeGcBreakage // breakage income when GC voided with balance + lifetimeGcBreakage // breakage income when GC voided with balance
- lifetimeDirectExp - lifetimeDirectExp
@@ -397,7 +533,7 @@ public class FinancialReportService : IFinancialReportService
- jeExpNet; - jeExpNet;
var accounts = await _context.Accounts var accounts = await _context.Accounts
.Where(a => a.IsActive) .Where(a => a.CompanyId == companyId && a.IsActive)
.OrderBy(a => a.AccountNumber) .OrderBy(a => a.AccountNumber)
.ToListAsync(); .ToListAsync();
@@ -425,11 +561,17 @@ public class FinancialReportService : IFinancialReportService
credits += taxByAcct.GetValueOrDefault(a.Id); credits += taxByAcct.GetValueOrDefault(a.Id);
credits += refundsByAcctBs.GetValueOrDefault(a.Id); // refunds reduce bank balance credits += refundsByAcctBs.GetValueOrDefault(a.Id); // refunds reduce bank balance
debits += depositsByAcctDepBs.GetValueOrDefault(a.Id); // deposits increase 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) if (gcLiabilityAcctIdBs.HasValue && a.Id == gcLiabilityAcctIdBs.Value)
{ {
credits += gcLiabilityCreditsBs; // GC issued → CR liability credits += gcLiabilityCreditsBs; // GC issued → CR liability
debits += gcLiabilityDebitsBs; // redeemed/voided → DR 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) if (custDepositsAcctIdBs.HasValue && a.Id == custDepositsAcctIdBs.Value)
{ {
credits += custDepositsCreditsBs; // deposits taken → CR liability credits += custDepositsCreditsBs; // deposits taken → CR liability
@@ -499,7 +641,8 @@ public class FinancialReportService : IFinancialReportService
var openInvoices = await _context.Invoices var openInvoices = await _context.Invoices
.Include(i => i.Customer) .Include(i => i.Customer)
.Where(i => i.Status != InvoiceStatus.Draft .Where(i => i.CompanyId == companyId
&& i.Status != InvoiceStatus.Draft
&& i.Status != InvoiceStatus.Voided && i.Status != InvoiceStatus.Voided
&& i.Status != InvoiceStatus.Paid && i.Status != InvoiceStatus.Paid
&& i.InvoiceDate <= asOfEnd && i.InvoiceDate <= asOfEnd
@@ -579,14 +722,15 @@ public class FinancialReportService : IFinancialReportService
var invoices = await _context.Invoices var invoices = await _context.Invoices
.Include(i => i.Customer) .Include(i => i.Customer)
.Include(i => i.Payments) .Include(i => i.Payments)
.Where(i => i.Status != InvoiceStatus.Draft .Where(i => i.CompanyId == companyId
&& i.Status != InvoiceStatus.Draft
&& i.Status != InvoiceStatus.Voided && i.Status != InvoiceStatus.Voided
&& i.InvoiceDate >= from && i.InvoiceDate <= toEnd) && i.InvoiceDate >= from && i.InvoiceDate <= toEnd)
.OrderBy(i => i.InvoiceDate) .OrderBy(i => i.InvoiceDate)
.ToListAsync(); .ToListAsync();
var collectedInPeriod = await _context.Payments var collectedInPeriod = await _context.Payments
.Where(p => p.PaymentDate >= from && p.PaymentDate <= toEnd) .Where(p => p.CompanyId == companyId && p.PaymentDate >= from && p.PaymentDate <= toEnd)
.SumAsync(p => (decimal?)p.Amount) ?? 0; .SumAsync(p => (decimal?)p.Amount) ?? 0;
var byCustomer = invoices var byCustomer = invoices
@@ -851,9 +995,8 @@ public class FinancialReportService : IFinancialReportService
// Bank/cash: customer payments deposited here (DR) // Bank/cash: customer payments deposited here (DR)
var depositsByAcct = await _context.Payments var depositsByAcct = await _context.Payments
.Where(p => p.PaymentDate <= asOfEnd && p.DepositAccountId != null .Where(p => p.CompanyId == companyId && p.PaymentDate <= asOfEnd && p.DepositAccountId != null
&& p.Invoice.Status != InvoiceStatus.Voided && p.Invoice.Status != InvoiceStatus.Voided)
&& p.Invoice.Status != InvoiceStatus.WrittenOff)
.GroupBy(p => p.DepositAccountId!.Value) .GroupBy(p => p.DepositAccountId!.Value)
.Select(g => new { Id = g.Key, Amt = g.Sum(p => p.Amount) }) .Select(g => new { Id = g.Key, Amt = g.Sum(p => p.Amount) })
.ToDictionaryAsync(g => g.Id, g => g.Amt); .ToDictionaryAsync(g => g.Id, g => g.Amt);
@@ -861,42 +1004,42 @@ public class FinancialReportService : IFinancialReportService
// AP: vendor credit applications reduce AP (DR) — credits are applied when a vendor // 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. // issues a credit note and it is matched against a specific bill.
var vcByApAcct = await _context.VendorCreditApplications var vcByApAcct = await _context.VendorCreditApplications
.Where(vca => vca.AppliedDate <= asOfEnd) .Where(vca => vca.CompanyId == companyId && vca.AppliedDate <= asOfEnd)
.GroupBy(vca => vca.VendorCredit.APAccountId) .GroupBy(vca => vca.VendorCredit.APAccountId)
.Select(g => new { Id = g.Key, Amt = g.Sum(vca => vca.Amount) }) .Select(g => new { Id = g.Key, Amt = g.Sum(vca => vca.Amount) })
.ToDictionaryAsync(g => g.Id, g => g.Amt); .ToDictionaryAsync(g => g.Id, g => g.Amt);
// Bank/cash: expenses paid from here (CR) // Bank/cash: expenses paid from here (CR)
var expFromByAcct = await _context.Expenses var expFromByAcct = await _context.Expenses
.Where(e => e.Date <= asOfEnd) .Where(e => e.CompanyId == companyId && e.Date <= asOfEnd)
.GroupBy(e => e.PaymentAccountId) .GroupBy(e => e.PaymentAccountId)
.Select(g => new { Id = g.Key, Amt = g.Sum(e => e.Amount) }) .Select(g => new { Id = g.Key, Amt = g.Sum(e => e.Amount) })
.ToDictionaryAsync(g => g.Id, g => g.Amt); .ToDictionaryAsync(g => g.Id, g => g.Amt);
// Bank/cash: bill payments made from here (CR) // Bank/cash: bill payments made from here (CR)
var bpFromByAcct = await _context.BillPayments var bpFromByAcct = await _context.BillPayments
.Where(bp => bp.PaymentDate <= asOfEnd) .Where(bp => bp.CompanyId == companyId && bp.PaymentDate <= asOfEnd)
.GroupBy(bp => bp.BankAccountId) .GroupBy(bp => bp.BankAccountId)
.Select(g => new { Id = g.Key, Amt = g.Sum(bp => bp.Amount) }) .Select(g => new { Id = g.Key, Amt = g.Sum(bp => bp.Amount) })
.ToDictionaryAsync(g => g.Id, g => g.Amt); .ToDictionaryAsync(g => g.Id, g => g.Amt);
// AP: bills increase AP (CR) // AP: bills increase AP (CR)
var billsByApAcct = await _context.Bills var billsByApAcct = await _context.Bills
.Where(b => b.Status != BillStatus.Draft && b.Status != BillStatus.Voided && b.BillDate <= asOfEnd) .Where(b => b.CompanyId == companyId && b.Status != BillStatus.Draft && b.Status != BillStatus.Voided && b.BillDate <= asOfEnd)
.GroupBy(b => b.APAccountId) .GroupBy(b => b.APAccountId)
.Select(g => new { Id = g.Key, Amt = g.Sum(b => b.Total) }) .Select(g => new { Id = g.Key, Amt = g.Sum(b => b.Total) })
.ToDictionaryAsync(g => g.Id, g => g.Amt); .ToDictionaryAsync(g => g.Id, g => g.Amt);
// AP: bill payments reduce AP (DR) // AP: bill payments reduce AP (DR)
var bpByApAcct = await _context.BillPayments var bpByApAcct = await _context.BillPayments
.Where(bp => bp.PaymentDate <= asOfEnd) .Where(bp => bp.CompanyId == companyId && bp.PaymentDate <= asOfEnd)
.GroupBy(bp => bp.Bill.APAccountId) .GroupBy(bp => bp.Bill.APAccountId)
.Select(g => new { Id = g.Key, Amt = g.Sum(bp => bp.Amount) }) .Select(g => new { Id = g.Key, Amt = g.Sum(bp => bp.Amount) })
.ToDictionaryAsync(g => g.Id, g => g.Amt); .ToDictionaryAsync(g => g.Id, g => g.Amt);
// Tax liability: sales tax collected (CR) // Tax liability: sales tax collected (CR)
var taxByAcct = await _context.Invoices var taxByAcct = await _context.Invoices
.Where(i => i.SalesTaxAccountId != null && i.TaxAmount > 0 .Where(i => i.CompanyId == companyId && i.SalesTaxAccountId != null && i.TaxAmount > 0
&& i.Status != InvoiceStatus.Draft && i.Status != InvoiceStatus.Voided && i.Status != InvoiceStatus.Draft && i.Status != InvoiceStatus.Voided
&& i.InvoiceDate <= asOfEnd) && i.InvoiceDate <= asOfEnd)
.GroupBy(i => i.SalesTaxAccountId!.Value) .GroupBy(i => i.SalesTaxAccountId!.Value)
@@ -905,7 +1048,7 @@ public class FinancialReportService : IFinancialReportService
// Revenue accounts: invoice line items (CR) // Revenue accounts: invoice line items (CR)
var revenueByAcct = await _context.InvoiceItems var revenueByAcct = await _context.InvoiceItems
.Where(ii => ii.RevenueAccountId != null .Where(ii => ii.CompanyId == companyId && ii.RevenueAccountId != null
&& ii.Invoice.Status != InvoiceStatus.Draft && ii.Invoice.Status != InvoiceStatus.Draft
&& ii.Invoice.Status != InvoiceStatus.Voided && ii.Invoice.Status != InvoiceStatus.Voided
&& ii.Invoice.InvoiceDate <= asOfEnd) && ii.Invoice.InvoiceDate <= asOfEnd)
@@ -915,14 +1058,14 @@ public class FinancialReportService : IFinancialReportService
// Expense accounts: direct expenses (DR) // Expense accounts: direct expenses (DR)
var expenseByAcct = await _context.Expenses var expenseByAcct = await _context.Expenses
.Where(e => e.Date <= asOfEnd) .Where(e => e.CompanyId == companyId && e.Date <= asOfEnd)
.GroupBy(e => e.ExpenseAccountId) .GroupBy(e => e.ExpenseAccountId)
.Select(g => new { Id = g.Key, Amt = g.Sum(e => e.Amount) }) .Select(g => new { Id = g.Key, Amt = g.Sum(e => e.Amount) })
.ToDictionaryAsync(g => g.Id, g => g.Amt); .ToDictionaryAsync(g => g.Id, g => g.Amt);
// Expense/COGS accounts: vendor bill line items (DR) // Expense/COGS accounts: vendor bill line items (DR)
var billLinesByAcct = await _context.BillLineItems var billLinesByAcct = await _context.BillLineItems
.Where(bli => bli.AccountId != null .Where(bli => bli.CompanyId == companyId && bli.AccountId != null
&& bli.Bill.Status != BillStatus.Draft && bli.Bill.Status != BillStatus.Draft
&& bli.Bill.Status != BillStatus.Voided && bli.Bill.Status != BillStatus.Voided
&& bli.Bill.BillDate <= asOfEnd) && bli.Bill.BillDate <= asOfEnd)
@@ -930,6 +1073,25 @@ public class FinancialReportService : IFinancialReportService
.Select(g => new { Id = g.Key, Amt = g.Sum(bli => bli.Amount) }) .Select(g => new { Id = g.Key, Amt = g.Sum(bli => bli.Amount) })
.ToDictionaryAsync(g => g.Id, g => g.Amt); .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). // 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. // 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. // Credit memo applications are also added to AR credits below so the double-entry balances.
@@ -944,33 +1106,50 @@ public class FinancialReportService : IFinancialReportService
.FirstOrDefaultAsync(); .FirstOrDefaultAsync();
var cmApplied = await _context.CreditMemoApplications var cmApplied = await _context.CreditMemoApplications
.Where(a => a.AppliedDate <= asOfEnd .Where(a => a.CompanyId == companyId && a.AppliedDate <= asOfEnd
&& a.Invoice.Status != InvoiceStatus.Voided) && a.Invoice.Status != InvoiceStatus.Voided)
.SumAsync(a => (decimal?)a.AmountApplied) ?? 0m; .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>(); var discountsByAcct = new Dictionary<int, decimal>();
if (discountAcctId.HasValue) if (discountAcctId.HasValue)
{ {
var totalDiscounts = await _context.Invoices var totalDiscounts = await _context.Invoices
.Where(i => i.DiscountAmount > 0 .Where(i => i.CompanyId == companyId && i.DiscountAmount > 0
&& i.Status != InvoiceStatus.Draft && i.Status != InvoiceStatus.Draft
&& i.Status != InvoiceStatus.Voided && i.Status != InvoiceStatus.Voided
&& i.InvoiceDate <= asOfEnd) && i.InvoiceDate <= asOfEnd)
.SumAsync(i => (decimal?)i.DiscountAmount) ?? 0m; .SumAsync(i => (decimal?)i.DiscountAmount) ?? 0m;
if (totalDiscounts + cmApplied > 0) if (totalDiscounts + cmContraRevenue > 0)
discountsByAcct[discountAcctId.Value] = totalDiscounts + cmApplied; discountsByAcct[discountAcctId.Value] = totalDiscounts + cmContraRevenue;
} }
// JE lines: posted entries debit/credit all account types // JE lines: posted entries debit/credit all account types
var jeDebitsByAcct = await _context.JournalEntryLines var jeDebitsByAcct = await _context.JournalEntryLines
.Where(l => l.JournalEntry.Status == JournalEntryStatus.Posted .Where(l => l.CompanyId == companyId && l.JournalEntry.Status == JournalEntryStatus.Posted
&& l.JournalEntry.EntryDate <= asOfEnd) && l.JournalEntry.EntryDate <= asOfEnd)
.GroupBy(l => l.AccountId) .GroupBy(l => l.AccountId)
.Select(g => new { Id = g.Key, Amt = g.Sum(l => l.DebitAmount) }) .Select(g => new { Id = g.Key, Amt = g.Sum(l => l.DebitAmount) })
.ToDictionaryAsync(g => g.Id, g => g.Amt); .ToDictionaryAsync(g => g.Id, g => g.Amt);
var jeCreditsByAcct = await _context.JournalEntryLines var jeCreditsByAcct = await _context.JournalEntryLines
.Where(l => l.JournalEntry.Status == JournalEntryStatus.Posted .Where(l => l.CompanyId == companyId && l.JournalEntry.Status == JournalEntryStatus.Posted
&& l.JournalEntry.EntryDate <= asOfEnd) && l.JournalEntry.EntryDate <= asOfEnd)
.GroupBy(l => l.AccountId) .GroupBy(l => l.AccountId)
.Select(g => new { Id = g.Key, Amt = g.Sum(l => l.CreditAmount) }) .Select(g => new { Id = g.Key, Amt = g.Sum(l => l.CreditAmount) })
@@ -980,25 +1159,48 @@ public class FinancialReportService : IFinancialReportService
// Credits include both cash payments and credit memo applications (which reduce open AR // Credits include both cash payments and credit memo applications (which reduce open AR
// when a customer credit is applied against a specific invoice). // when a customer credit is applied against a specific invoice).
var arTotalDebits = await _context.Invoices var arTotalDebits = await _context.Invoices
.Where(i => i.Status != InvoiceStatus.Draft && i.Status != InvoiceStatus.Voided .Where(i => i.CompanyId == companyId && i.Status != InvoiceStatus.Draft && i.Status != InvoiceStatus.Voided
&& i.InvoiceDate <= asOfEnd) && i.InvoiceDate <= asOfEnd)
.SumAsync(i => (decimal?)i.Total) ?? 0m; .SumAsync(i => (decimal?)i.Total) ?? 0m;
var arTotalCredits = await _context.Payments var arTotalCredits = await _context.Payments
.Where(p => p.PaymentDate <= asOfEnd .Where(p => p.CompanyId == companyId && p.PaymentDate <= asOfEnd
&& p.Invoice.Status != InvoiceStatus.Voided && p.Invoice.Status != InvoiceStatus.Voided)
&& p.Invoice.Status != InvoiceStatus.WrittenOff)
.SumAsync(p => (decimal?)p.Amount) ?? 0m; .SumAsync(p => (decimal?)p.Amount) ?? 0m;
arTotalCredits += cmApplied; // credit memo applications reduce AR balance 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;
// Refunds reverse collected payments — reduce net AR credits (re-opens the receivable). // Cash refunds reverse the sale: revenue portion → DR Sales Returns (4960), tax portion →
var refundTotal = await _context.Refunds // DR Sales Tax Payable (relieves the liability), cash → CR bank (refundsByAcct below). They no
.Where(r => r.RefundDate <= asOfEnd && !r.IsDeleted) // longer touch AR. Store-credit refunds post via CreditMemo, not the GL, so are excluded.
.SumAsync(r => (decimal?)r.Amount) ?? 0m; var saleReversingRefunds = await _context.Refunds
arTotalCredits -= refundTotal; .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 by bank account: money leaving the account (CR to checking/bank). // Refunds by bank account: money leaving the account (CR to checking/bank).
var refundsByAcct = await _context.Refunds var refundsByAcct = await _context.Refunds
.Where(r => r.RefundDate <= asOfEnd && !r.IsDeleted && r.DepositAccountId != null) .Where(r => r.CompanyId == companyId && r.RefundDate <= asOfEnd && !r.IsDeleted && r.DepositAccountId != null)
.GroupBy(r => r.DepositAccountId!.Value) .GroupBy(r => r.DepositAccountId!.Value)
.Select(g => new { Id = g.Key, Amt = g.Sum(r => r.Amount) }) .Select(g => new { Id = g.Key, Amt = g.Sum(r => r.Amount) })
.ToDictionaryAsync(g => g.Id, g => g.Amt); .ToDictionaryAsync(g => g.Id, g => g.Amt);
@@ -1006,7 +1208,7 @@ public class FinancialReportService : IFinancialReportService
// Deposits by bank account: cash received at deposit recording time (DR bank). // 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. // Deposit-sourced Payments have DepositAccountId = null, so there is no double-count with depositsByAcct.
var depositsByAcctDep = await _context.Deposits var depositsByAcctDep = await _context.Deposits
.Where(d => !d.IsDeleted && d.DepositAccountId != null && d.ReceivedDate <= asOfEnd) .Where(d => d.CompanyId == companyId && !d.IsDeleted && d.DepositAccountId != null && d.ReceivedDate <= asOfEnd)
.GroupBy(d => d.DepositAccountId!.Value) .GroupBy(d => d.DepositAccountId!.Value)
.Select(g => new { Id = g.Key, Amt = g.Sum(d => d.Amount) }) .Select(g => new { Id = g.Key, Amt = g.Sum(d => d.Amount) })
.ToDictionaryAsync(g => g.Id, g => g.Amt); .ToDictionaryAsync(g => g.Id, g => g.Amt);
@@ -1017,11 +1219,11 @@ public class FinancialReportService : IFinancialReportService
.Select(a => (int?)a.Id).FirstOrDefaultAsync(); .Select(a => (int?)a.Id).FirstOrDefaultAsync();
var custDepositsCredits = custDepositsAcctId.HasValue var custDepositsCredits = custDepositsAcctId.HasValue
? (await _context.Deposits ? (await _context.Deposits
.Where(d => !d.IsDeleted && d.ReceivedDate <= asOfEnd) .Where(d => d.CompanyId == companyId && !d.IsDeleted && d.ReceivedDate <= asOfEnd)
.SumAsync(d => (decimal?)d.Amount) ?? 0m) : 0m; .SumAsync(d => (decimal?)d.Amount) ?? 0m) : 0m;
var custDepositsDebits = custDepositsAcctId.HasValue var custDepositsDebits = custDepositsAcctId.HasValue
? (await _context.Deposits ? (await _context.Deposits
.Where(d => !d.IsDeleted && d.AppliedToInvoiceId != null && d.AppliedDate <= asOfEnd) .Where(d => d.CompanyId == companyId && !d.IsDeleted && d.AppliedToInvoiceId != null && d.AppliedDate <= asOfEnd)
.SumAsync(d => (decimal?)d.Amount) ?? 0m) : 0m; .SumAsync(d => (decimal?)d.Amount) ?? 0m) : 0m;
// Gift Certificate Liability (2500): balance driven by GC issuances, redemptions, and voids. // Gift Certificate Liability (2500): balance driven by GC issuances, redemptions, and voids.
@@ -1030,14 +1232,14 @@ public class FinancialReportService : IFinancialReportService
.Select(a => (int?)a.Id).FirstOrDefaultAsync(); .Select(a => (int?)a.Id).FirstOrDefaultAsync();
var gcLiabilityCredits = gcLiabilityAcctId.HasValue var gcLiabilityCredits = gcLiabilityAcctId.HasValue
? (await _context.GiftCertificates ? (await _context.GiftCertificates
.Where(gc => !gc.IsDeleted && gc.IssueDate <= asOfEnd) .Where(gc => gc.CompanyId == companyId && !gc.IsDeleted && gc.IssueDate <= asOfEnd)
.SumAsync(gc => (decimal?)gc.OriginalAmount) ?? 0m) : 0m; .SumAsync(gc => (decimal?)gc.OriginalAmount) ?? 0m) : 0m;
var gcLiabilityDebits = gcLiabilityAcctId.HasValue var gcLiabilityDebits = gcLiabilityAcctId.HasValue
? ((await _context.GiftCertificateRedemptions ? ((await _context.GiftCertificateRedemptions
.Where(r => !r.IsDeleted && r.RedeemedDate <= asOfEnd) .Where(r => r.CompanyId == companyId && !r.IsDeleted && r.RedeemedDate <= asOfEnd)
.SumAsync(r => (decimal?)r.AmountRedeemed) ?? 0m) .SumAsync(r => (decimal?)r.AmountRedeemed) ?? 0m)
+ (await _context.GiftCertificates + (await _context.GiftCertificates
.Where(gc => !gc.IsDeleted && gc.Status == GiftCertificateStatus.Voided .Where(gc => gc.CompanyId == companyId && !gc.IsDeleted && gc.Status == GiftCertificateStatus.Voided
&& gc.UpdatedAt <= asOfEnd && gc.OriginalAmount > gc.RedeemedAmount) && gc.UpdatedAt <= asOfEnd && gc.OriginalAmount > gc.RedeemedAmount)
.SumAsync(gc => (decimal?)(gc.OriginalAmount - gc.RedeemedAmount)) ?? 0m)) : 0m; .SumAsync(gc => (decimal?)(gc.OriginalAmount - gc.RedeemedAmount)) ?? 0m)) : 0m;
@@ -1077,8 +1279,13 @@ public class FinancialReportService : IFinancialReportService
debits += expenseByAcct.GetValueOrDefault(a.Id); debits += expenseByAcct.GetValueOrDefault(a.Id);
debits += billLinesByAcct.GetValueOrDefault(a.Id); debits += billLinesByAcct.GetValueOrDefault(a.Id);
debits += discountsByAcct.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 credits += refundsByAcct.GetValueOrDefault(a.Id); // refunds reduce bank balance
debits += depositsByAcctDep.GetValueOrDefault(a.Id); // deposits increase 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) if (gcLiabilityAcctId.HasValue && a.Id == gcLiabilityAcctId.Value)
{ {
credits += gcLiabilityCredits; // GC issued → CR liability credits += gcLiabilityCredits; // GC issued → CR liability
@@ -1089,6 +1296,11 @@ public class FinancialReportService : IFinancialReportService
credits += custDepositsCredits; // deposits taken → CR liability credits += custDepositsCredits; // deposits taken → CR liability
debits += custDepositsDebits; // deposits applied → DR 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) // Manual JEs apply to all account types (including AR/AP for unusual adjustments)
@@ -1175,17 +1387,17 @@ public class FinancialReportService : IFinancialReportService
// Opening balance: invoiced paid before period start // Opening balance: invoiced paid before period start
var preInvoiced = await _context.Invoices var preInvoiced = await _context.Invoices
.Where(i => i.CustomerId == customerId .Where(i => i.CompanyId == companyId && i.CustomerId == customerId
&& i.Status != InvoiceStatus.Draft && i.Status != InvoiceStatus.Voided && i.Status != InvoiceStatus.Draft && i.Status != InvoiceStatus.Voided
&& i.InvoiceDate < from) && i.InvoiceDate < from)
.SumAsync(i => (decimal?)i.Total) ?? 0; .SumAsync(i => (decimal?)i.Total) ?? 0;
var prePaid = await _context.Payments var prePaid = await _context.Payments
.Where(p => p.Invoice.CustomerId == customerId .Where(p => p.CompanyId == companyId && p.Invoice.CustomerId == customerId
&& p.Invoice.Status != InvoiceStatus.Voided && p.Invoice.Status != InvoiceStatus.Voided
&& p.PaymentDate < from) && p.PaymentDate < from)
.SumAsync(p => (decimal?)p.Amount) ?? 0; .SumAsync(p => (decimal?)p.Amount) ?? 0;
var preCredits = await _context.CreditMemoApplications var preCredits = await _context.CreditMemoApplications
.Where(a => a.Invoice.CustomerId == customerId && a.AppliedDate < from) .Where(a => a.CompanyId == companyId && a.Invoice.CustomerId == customerId && a.AppliedDate < from)
.SumAsync(a => (decimal?)a.AmountApplied) ?? 0; .SumAsync(a => (decimal?)a.AmountApplied) ?? 0;
var openingBalance = preInvoiced - prePaid - preCredits; var openingBalance = preInvoiced - prePaid - preCredits;
@@ -1194,7 +1406,7 @@ public class FinancialReportService : IFinancialReportService
var lines = new List<StatementLineDto>(); var lines = new List<StatementLineDto>();
var periodInvoices = await _context.Invoices var periodInvoices = await _context.Invoices
.Where(i => i.CustomerId == customerId .Where(i => i.CompanyId == companyId && i.CustomerId == customerId
&& i.Status != InvoiceStatus.Draft && i.Status != InvoiceStatus.Voided && i.Status != InvoiceStatus.Draft && i.Status != InvoiceStatus.Voided
&& i.InvoiceDate >= from && i.InvoiceDate <= toEnd) && i.InvoiceDate >= from && i.InvoiceDate <= toEnd)
.AsNoTracking().ToListAsync(); .AsNoTracking().ToListAsync();
@@ -1211,7 +1423,7 @@ public class FinancialReportService : IFinancialReportService
var periodPayments = await _context.Payments var periodPayments = await _context.Payments
.Include(p => p.Invoice) .Include(p => p.Invoice)
.Where(p => p.Invoice.CustomerId == customerId .Where(p => p.CompanyId == companyId && p.Invoice.CustomerId == customerId
&& p.Invoice.Status != InvoiceStatus.Voided && p.Invoice.Status != InvoiceStatus.Voided
&& p.PaymentDate >= from && p.PaymentDate <= toEnd) && p.PaymentDate >= from && p.PaymentDate <= toEnd)
.AsNoTracking().ToListAsync(); .AsNoTracking().ToListAsync();
@@ -1229,7 +1441,7 @@ public class FinancialReportService : IFinancialReportService
var periodCredits = await _context.CreditMemoApplications var periodCredits = await _context.CreditMemoApplications
.Include(a => a.Invoice) .Include(a => a.Invoice)
.Include(a => a.CreditMemo) .Include(a => a.CreditMemo)
.Where(a => a.Invoice.CustomerId == customerId .Where(a => a.CompanyId == companyId && a.Invoice.CustomerId == customerId
&& a.AppliedDate >= from && a.AppliedDate <= toEnd) && a.AppliedDate >= from && a.AppliedDate <= toEnd)
.AsNoTracking().ToListAsync(); .AsNoTracking().ToListAsync();
@@ -1280,15 +1492,15 @@ public class FinancialReportService : IFinancialReportService
// Opening balance: bills payments credits before period start // Opening balance: bills payments credits before period start
var preBills = await _context.Bills var preBills = await _context.Bills
.Where(b => b.VendorId == vendorId .Where(b => b.CompanyId == companyId && b.VendorId == vendorId
&& b.Status != BillStatus.Draft && b.Status != BillStatus.Voided && b.Status != BillStatus.Draft && b.Status != BillStatus.Voided
&& b.BillDate < from) && b.BillDate < from)
.SumAsync(b => (decimal?)b.Total) ?? 0; .SumAsync(b => (decimal?)b.Total) ?? 0;
var prePayments = await _context.BillPayments var prePayments = await _context.BillPayments
.Where(bp => bp.Bill.VendorId == vendorId && bp.PaymentDate < from) .Where(bp => bp.CompanyId == companyId && bp.Bill.VendorId == vendorId && bp.PaymentDate < from)
.SumAsync(bp => (decimal?)bp.Amount) ?? 0; .SumAsync(bp => (decimal?)bp.Amount) ?? 0;
var preVcApplied = await _context.VendorCreditApplications var preVcApplied = await _context.VendorCreditApplications
.Where(vca => vca.Bill.VendorId == vendorId && vca.AppliedDate < from) .Where(vca => vca.CompanyId == companyId && vca.Bill.VendorId == vendorId && vca.AppliedDate < from)
.SumAsync(vca => (decimal?)vca.Amount) ?? 0; .SumAsync(vca => (decimal?)vca.Amount) ?? 0;
var openingBalance = preBills - prePayments - preVcApplied; var openingBalance = preBills - prePayments - preVcApplied;
@@ -1296,7 +1508,7 @@ public class FinancialReportService : IFinancialReportService
var lines = new List<StatementLineDto>(); var lines = new List<StatementLineDto>();
var periodBills = await _context.Bills var periodBills = await _context.Bills
.Where(b => b.VendorId == vendorId .Where(b => b.CompanyId == companyId && b.VendorId == vendorId
&& b.Status != BillStatus.Draft && b.Status != BillStatus.Voided && b.Status != BillStatus.Draft && b.Status != BillStatus.Voided
&& b.BillDate >= from && b.BillDate <= toEnd) && b.BillDate >= from && b.BillDate <= toEnd)
.AsNoTracking().ToListAsync(); .AsNoTracking().ToListAsync();
@@ -1313,7 +1525,7 @@ public class FinancialReportService : IFinancialReportService
var periodPayments = await _context.BillPayments var periodPayments = await _context.BillPayments
.Include(bp => bp.Bill) .Include(bp => bp.Bill)
.Where(bp => bp.Bill.VendorId == vendorId .Where(bp => bp.CompanyId == companyId && bp.Bill.VendorId == vendorId
&& bp.PaymentDate >= from && bp.PaymentDate <= toEnd) && bp.PaymentDate >= from && bp.PaymentDate <= toEnd)
.AsNoTracking().ToListAsync(); .AsNoTracking().ToListAsync();
@@ -1330,7 +1542,7 @@ public class FinancialReportService : IFinancialReportService
var periodVcApplied = await _context.VendorCreditApplications var periodVcApplied = await _context.VendorCreditApplications
.Include(vca => vca.VendorCredit) .Include(vca => vca.VendorCredit)
.Include(vca => vca.Bill) .Include(vca => vca.Bill)
.Where(vca => vca.Bill.VendorId == vendorId .Where(vca => vca.CompanyId == companyId && vca.Bill.VendorId == vendorId
&& vca.AppliedDate >= from && vca.AppliedDate <= toEnd) && vca.AppliedDate >= from && vca.AppliedDate <= toEnd)
.AsNoTracking().ToListAsync(); .AsNoTracking().ToListAsync();
@@ -6,6 +6,7 @@ using Anthropic.SDK.Messaging;
using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using PowderCoating.Application.Interfaces; using PowderCoating.Application.Interfaces;
using PowderCoating.Core.Entities;
using PowderCoating.Core.Interfaces; using PowderCoating.Core.Interfaces;
namespace PowderCoating.Infrastructure.Services; namespace PowderCoating.Infrastructure.Services;
@@ -541,18 +542,20 @@ Rules:
// Targeted prompt: we only need cure specs from this document // Targeted prompt: we only need cure specs from this document
const string curePrompt = @"You are reading a Technical Data Sheet (TDS) for a powder coating product. const string curePrompt = @"You are reading a Technical Data Sheet (TDS) for a powder coating product.
Extract ONLY the cure schedule. Respond with a valid JSON object no markdown, no explanation: Extract the cure schedule and the specific gravity. Respond with a valid JSON object no markdown, no explanation:
{ {
""cureTemperatureF"": number or null, ""cureTemperatureF"": number or null,
""cureTimeMinutes"": number or null, ""cureTimeMinutes"": number or null,
""reasoning"": ""one sentence: what cure schedule you found"" ""specificGravity"": number or null,
""reasoning"": ""one sentence: what cure schedule and specific gravity you found""
} }
Rules: Rules:
- cureTemperatureF: the recommended cure temperature in °F. Convert from °C if needed (multiply by 1.8 + 32). If a range is given use the midpoint. Most powders cure 325400 °F. - cureTemperatureF: the recommended cure temperature in °F. Convert from °C if needed (multiply by 1.8 + 32). If a range is given use the midpoint. Most powders cure 325400 °F.
- cureTimeMinutes: the hold time at cure temperature in minutes (NOT total oven time). Typically 1020 min. - cureTimeMinutes: the hold time at cure temperature in minutes (NOT total oven time). Typically 1020 min.
- If neither value can be found in the document, return null for both."; - specificGravity: the specific gravity / density value from the TDS (often labeled ""Specific Gravity"" or ""Density""). Typically 1.21.8. Null if not stated.
- Return null for any value not found in the document.";
var sb = new StringBuilder(); var sb = new StringBuilder();
sb.AppendLine("Technical Data Sheet content:"); sb.AppendLine("Technical Data Sheet content:");
@@ -603,11 +606,12 @@ Rules:
Success = true, Success = true,
CureTemperatureF = GetDecimal(parsed, "cureTemperatureF"), CureTemperatureF = GetDecimal(parsed, "cureTemperatureF"),
CureTimeMinutes = GetInt(parsed, "cureTimeMinutes"), CureTimeMinutes = GetInt(parsed, "cureTimeMinutes"),
SpecificGravity = GetDecimal(parsed, "specificGravity"),
Reasoning = GetString(parsed, "reasoning"), Reasoning = GetString(parsed, "reasoning"),
}; };
_logger.LogInformation("TDS cure lookup for {Url}: temp={Temp}°F, time={Time}min ({Reasoning})", _logger.LogInformation("TDS spec lookup for {Url}: temp={Temp}°F, time={Time}min, sg={Sg} ({Reasoning})",
tdsUrl, result.CureTemperatureF, result.CureTimeMinutes, result.Reasoning); tdsUrl, result.CureTemperatureF, result.CureTimeMinutes, result.SpecificGravity, result.Reasoning);
return result; return result;
} }
catch (Exception ex) catch (Exception ex)
@@ -617,6 +621,58 @@ Rules:
} }
} }
/// <inheritdoc />
public async Task<bool> EnsureCatalogTdsSpecsAsync(PowderCatalogItem catalog)
{
// Already enriched, or nothing to read from. Specific gravity is the trigger: it's never in
// the API feed, so its absence means this item hasn't been TDS-enriched yet.
if (catalog == null || catalog.SpecificGravity.HasValue || string.IsNullOrWhiteSpace(catalog.TdsUrl))
return false;
var tds = await FetchTdsCureSpecsAsync(catalog.TdsUrl, catalog.ColorName);
if (!tds.Success)
return false;
var changed = false;
if (tds.SpecificGravity is > 0)
{
catalog.SpecificGravity = tds.SpecificGravity;
changed = true;
}
if (!catalog.CureTemperatureF.HasValue && tds.CureTemperatureF.HasValue)
{
catalog.CureTemperatureF = tds.CureTemperatureF;
changed = true;
}
if (!catalog.CureTimeMinutes.HasValue && tds.CureTimeMinutes.HasValue)
{
catalog.CureTimeMinutes = tds.CureTimeMinutes;
changed = true;
}
// Derive theoretical coverage once specific gravity is known.
if (!catalog.CoverageSqFtPerLb.HasValue && catalog.SpecificGravity is > 0)
{
catalog.CoverageSqFtPerLb = Math.Round(
TheoreticalCoverageConstant / (catalog.SpecificGravity.Value * DefaultCoverageThicknessMils),
2, MidpointRounding.AwayFromZero);
changed = true;
}
if (changed)
{
catalog.UpdatedAt = DateTime.UtcNow;
await _unitOfWork.PowderCatalog.UpdateAsync(catalog);
await _unitOfWork.CompleteAsync();
_logger.LogInformation(
"Lazily enriched catalog item {Vendor} {Sku} from TDS: sg={Sg}, cure={Temp}F/{Time}min, coverage={Cov}",
catalog.VendorName, catalog.Sku, catalog.SpecificGravity, catalog.CureTemperatureF,
catalog.CureTimeMinutes, catalog.CoverageSqFtPerLb);
}
return changed;
}
// ── Manufacturer URL pattern: build direct product page URL ─────────────── // ── Manufacturer URL pattern: build direct product page URL ───────────────
/// <summary> /// <summary>
@@ -2,6 +2,7 @@ using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using PowderCoating.Application.DTOs.Accounting; using PowderCoating.Application.DTOs.Accounting;
using PowderCoating.Application.Interfaces; using PowderCoating.Application.Interfaces;
using PowderCoating.Core.Accounting;
using PowderCoating.Core.Enums; using PowderCoating.Core.Enums;
using PowderCoating.Infrastructure.Data; using PowderCoating.Infrastructure.Data;
@@ -199,6 +200,43 @@ public class LedgerService : ILedgerService
LinkId = inv.Id 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) ──────────── // ── 6. Direct expenses categorized to this account (DEBIT) ────────────
// e.g. Expense account 6200 receives direct expense entries // e.g. Expense account 6200 receives direct expense entries
var expensesTo = await _context.Expenses var expensesTo = await _context.Expenses
@@ -312,24 +350,29 @@ public class LedgerService : ILedgerService
LinkId = cm.InvoiceId LinkId = cm.InvoiceId
}); });
// Refunds re-open AR (DEBIT — customer owes again after refund) // Gift-certificate redemptions reduce open AR (CREDIT)ApplyGiftCertificate posts DR 2500 / CR AR.
var arRefunds = await _context.Refunds var arGcRedemptions = await _context.GiftCertificateRedemptions
.Include(r => r.Invoice) .Include(r => r.Invoice)
.Where(r => r.RefundDate >= fromDate && r.RefundDate <= toDate && !r.IsDeleted) .Include(r => r.GiftCertificate)
.Where(r => !r.IsDeleted && r.RedeemedDate >= fromDate && r.RedeemedDate <= toDate
&& r.Invoice.Status != InvoiceStatus.Voided)
.ToListAsync(); .ToListAsync();
foreach (var r in arRefunds) foreach (var r in arGcRedemptions)
entries.Add(new LedgerEntryDto entries.Add(new LedgerEntryDto
{ {
Date = r.RefundDate, Date = r.RedeemedDate,
Reference = r.Reference ?? $"REF-{r.Id}", Reference = r.GiftCertificate?.CertificateCode ?? $"GC-{r.GiftCertificateId}",
Source = "Refund", Source = "Gift Certificate",
Description = r.Reason, Description = $"GC redeemed on {r.Invoice?.InvoiceNumber}",
Debit = r.Amount, Debit = 0,
Credit = 0, Credit = r.AmountRedeemed,
LinkController = "Invoices", LinkController = "Invoices",
LinkId = r.InvoiceId 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 ──────────────────────────────────────────────── // ── 9. Accounts Payable ────────────────────────────────────────────────
@@ -473,6 +516,125 @@ 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 ────────────────── // ── 10. Journal Entry lines touching this account ──────────────────
var jeLines = await _context.JournalEntryLines var jeLines = await _context.JournalEntryLines
.Include(l => l.JournalEntry) .Include(l => l.JournalEntry)
@@ -594,6 +756,27 @@ public class LedgerService : ILedgerService
&& i.InvoiceDate < beforeDate) && i.InvoiceDate < beforeDate)
.SumAsync(i => (decimal?)i.TaxAmount) ?? 0; .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) // 6. Direct expenses categorized to this account (DEBIT)
debits += await _context.Expenses debits += await _context.Expenses
.Where(e => e.ExpenseAccountId == accountId && e.Date < beforeDate) .Where(e => e.ExpenseAccountId == accountId && e.Date < beforeDate)
@@ -624,9 +807,13 @@ public class LedgerService : ILedgerService
.Where(a => a.AppliedDate < beforeDate && a.Invoice.Status != InvoiceStatus.Voided) .Where(a => a.AppliedDate < beforeDate && a.Invoice.Status != InvoiceStatus.Voided)
.SumAsync(a => (decimal?)a.AmountApplied) ?? 0; .SumAsync(a => (decimal?)a.AmountApplied) ?? 0;
debits += await _context.Refunds // Gift-certificate redemptions credit AR (DR 2500 / CR AR), same as in GetAccountLedgerAsync.
.Where(r => !r.IsDeleted && r.RefundDate < beforeDate) credits += await _context.GiftCertificateRedemptions
.SumAsync(r => (decimal?)r.Amount) ?? 0; .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.
} }
// 9. Accounts Payable // 9. Accounts Payable
@@ -674,6 +861,55 @@ public class LedgerService : ILedgerService
.SumAsync(d => (decimal?)d.Amount) ?? 0; .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) // 10. Posted journal entry lines touching this account (prior to period)
debits += await _context.JournalEntryLines debits += await _context.JournalEntryLines
.Where(l => l.AccountId == accountId .Where(l => l.AccountId == accountId
@@ -0,0 +1,303 @@
using Microsoft.Extensions.Logging;
using PowderCoating.Application.Interfaces;
using PowderCoating.Core.Entities;
using PowderCoating.Core.Interfaces;
namespace PowderCoating.Infrastructure.Services;
/// <summary>
/// Single upsert path for the platform <see cref="PowderCatalogItem"/> master list, shared by the
/// JSON file import and the Columbia API sync. Match key is (VendorName, SKU), case-insensitive.
/// </summary>
public class PowderCatalogUpsertService : IPowderCatalogUpsertService
{
private readonly IUnitOfWork _unitOfWork;
private readonly ILogger<PowderCatalogUpsertService> _logger;
public PowderCatalogUpsertService(IUnitOfWork unitOfWork, ILogger<PowderCatalogUpsertService> logger)
{
_unitOfWork = unitOfWork;
_logger = logger;
}
/// <inheritdoc />
public async Task<PowderCatalogUpsertResult> UpsertAsync(
IReadOnlyList<PowderCatalogItem> incoming,
DateTime runTimestamp,
CancellationToken cancellationToken = default)
{
var result = new PowderCatalogUpsertResult();
// Load existing rows for just the vendors we're touching, keyed by (vendor|sku) lower-cased.
var vendorNames = incoming
.Select(i => i.VendorName)
.Where(v => !string.IsNullOrWhiteSpace(v))
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
var existing = (await _unitOfWork.PowderCatalog.FindAsync(p => vendorNames.Contains(p.VendorName)))
.ToDictionary(KeyOf, StringComparer.OrdinalIgnoreCase);
var toAdd = new List<PowderCatalogItem>();
foreach (var item in incoming)
{
if (string.IsNullOrWhiteSpace(item.Sku) || string.IsNullOrWhiteSpace(item.ColorName))
{
result.Skipped++;
continue;
}
if (existing.TryGetValue(KeyOf(item), out var record))
{
if (ApplyFeedFields(record, item))
{
record.UpdatedAt = runTimestamp;
record.LastSyncedAt = runTimestamp;
await _unitOfWork.PowderCatalog.UpdateAsync(record);
result.Updated++;
}
else
{
result.Unchanged++;
}
}
else
{
item.CreatedAt = runTimestamp;
item.LastSyncedAt = runTimestamp;
toAdd.Add(item);
result.Inserted++;
}
}
if (toAdd.Count > 0)
await _unitOfWork.PowderCatalog.AddRangeAsync(toAdd);
await _unitOfWork.CompleteAsync();
// Push current catalog price + product data down to any tenant inventory linked to these
// catalog rows, so quotes reflect the current price.
var propagated = await PropagateToLinkedInventoryAsync();
_logger.LogInformation(
"Powder catalog upsert: {Inserted} inserted, {Updated} updated, {Unchanged} unchanged, {Skipped} skipped; {Propagated} linked inventory item(s) refreshed.",
result.Inserted, result.Updated, result.Unchanged, result.Skipped, propagated);
return result;
}
/// <summary>
/// Keeps tenant inventory in step with the catalog (across all companies): first self-heals by
/// linking any unlinked item to its catalog row by identity, then refreshes every linked item
/// with the catalog's current price and product data. Returns the number of items touched.
/// </summary>
public async Task<int> PropagateToLinkedInventoryAsync()
{
var linkedCount = await LinkUnlinkedInventoryAsync();
var refreshedCount = await RefreshLinkedInventoryAsync();
return linkedCount + refreshedCount;
}
/// <summary>
/// Self-heals the catalog link: finds inventory items with no <see cref="InventoryItem.PowderCatalogItemId"/>
/// that match a catalog row by Manufacturer + ManufacturerPartNumber (the catalog SKU), sets the
/// FK, and applies the catalog price/product data. Only links on a confident match (exact SKU,
/// matching vendor, or a single unambiguous candidate) so it never mis-links. Returns the count
/// newly linked. This backfills items created before linking existed, on every environment, with
/// no manual step.
/// </summary>
private async Task<int> LinkUnlinkedInventoryAsync()
{
var unlinked = (await _unitOfWork.InventoryItems.FindAsync(
i => i.PowderCatalogItemId == null
&& i.Manufacturer != null && i.Manufacturer != ""
&& i.ManufacturerPartNumber != null && i.ManufacturerPartNumber != "",
ignoreQueryFilters: true)).ToList();
if (unlinked.Count == 0)
return 0;
var partNumbers = unlinked.Select(i => i.ManufacturerPartNumber!).Distinct().ToList();
var bySku = (await _unitOfWork.PowderCatalog.FindAsync(p => partNumbers.Contains(p.Sku)))
.GroupBy(c => c.Sku, StringComparer.OrdinalIgnoreCase)
.ToDictionary(g => g.Key, g => g.ToList(), StringComparer.OrdinalIgnoreCase);
if (bySku.Count == 0)
return 0;
var linked = 0;
foreach (var inv in unlinked)
{
if (!bySku.TryGetValue(inv.ManufacturerPartNumber!, out var candidates))
continue;
var mfr = inv.Manufacturer!.Trim().ToLower();
var match = candidates.FirstOrDefault(c => c.VendorName.ToLower().Contains(mfr))
?? (candidates.Count == 1 ? candidates[0] : null);
if (match == null)
continue;
inv.PowderCatalogItemId = match.Id;
ApplyCatalogToLinkedInventory(inv, match);
await _unitOfWork.InventoryItems.UpdateAsync(inv);
linked++;
}
if (linked > 0)
await _unitOfWork.CompleteAsync();
return linked;
}
/// <summary>
/// Refreshes every tenant inventory item linked to a powder catalog row (across all companies)
/// with the catalog's current list price and product data. Sets
/// <see cref="InventoryItem.CatalogReferencePrice"/> (the QUOTING price) and product spec/doc
/// fields, but NEVER the cost basis (UnitCost/AverageCost/LastPurchasePrice), quantity, notes,
/// image, location, or stock levels — those are tenant-owned. EF persists only items that
/// actually changed, so this is a cheap no-op when nothing moved. Returns the number updated.
/// </summary>
private async Task<int> RefreshLinkedInventoryAsync()
{
var linked = (await _unitOfWork.InventoryItems.FindAsync(
i => i.PowderCatalogItemId != null, ignoreQueryFilters: true)).ToList();
if (linked.Count == 0)
return 0;
var catalogIds = linked.Select(i => i.PowderCatalogItemId!.Value).Distinct().ToList();
var catalogById = (await _unitOfWork.PowderCatalog.FindAsync(p => catalogIds.Contains(p.Id)))
.ToDictionary(p => p.Id);
var updated = 0;
foreach (var inv in linked)
{
if (!catalogById.TryGetValue(inv.PowderCatalogItemId!.Value, out var cat))
continue;
if (ApplyCatalogToLinkedInventory(inv, cat))
{
await _unitOfWork.InventoryItems.UpdateAsync(inv);
updated++;
}
}
if (updated > 0)
await _unitOfWork.CompleteAsync();
return updated;
}
/// <summary>
/// Applies the catalog's current price and product data onto a linked inventory item, returning
/// true if anything changed. Sets the quoting reference price (only when the catalog has a real
/// price &gt; 0) and refreshes product/spec fields where the catalog has a value — never erasing
/// tenant data with catalog nulls, and never touching cost basis, quantity, notes, image, or
/// stock levels.
/// </summary>
private static bool ApplyCatalogToLinkedInventory(InventoryItem inv, PowderCatalogItem cat)
{
var changed = false;
// Quoting price (the point of this): keep the current catalog list price, separate from cost.
if (cat.UnitPrice > 0 && inv.CatalogReferencePrice != cat.UnitPrice)
{
inv.CatalogReferencePrice = cat.UnitPrice;
inv.CatalogPriceUpdatedAt = DateTime.UtcNow;
changed = true;
}
// Product data — refresh from the catalog where it has a value (catalog is authoritative on
// these); do not null out a tenant value the catalog doesn't carry.
changed |= SetStrIfCatalogHas(() => inv.Description, v => inv.Description = v, cat.Description);
changed |= SetStrIfCatalogHas(() => inv.Finish, v => inv.Finish = v, cat.Finish);
changed |= SetStrIfCatalogHas(() => inv.ColorFamilies, v => inv.ColorFamilies = v, cat.ColorFamilies);
changed |= SetStrIfCatalogHas(() => inv.SdsUrl, v => inv.SdsUrl = v, cat.SdsUrl);
changed |= SetStrIfCatalogHas(() => inv.TdsUrl, v => inv.TdsUrl = v, cat.TdsUrl);
changed |= SetStrIfCatalogHas(() => inv.SpecPageUrl, v => inv.SpecPageUrl = v, cat.ProductUrl);
if (cat.CureTemperatureF.HasValue && inv.CureTemperatureF != cat.CureTemperatureF)
{ inv.CureTemperatureF = cat.CureTemperatureF; changed = true; }
if (cat.CureTimeMinutes.HasValue && inv.CureTimeMinutes != cat.CureTimeMinutes)
{ inv.CureTimeMinutes = cat.CureTimeMinutes; changed = true; }
if (cat.CoverageSqFtPerLb.HasValue && inv.CoverageSqFtPerLb != cat.CoverageSqFtPerLb)
{ inv.CoverageSqFtPerLb = cat.CoverageSqFtPerLb; changed = true; }
if (cat.SpecificGravity.HasValue && inv.SpecificGravity != cat.SpecificGravity)
{ inv.SpecificGravity = cat.SpecificGravity; changed = true; }
if (cat.TransferEfficiency.HasValue && inv.TransferEfficiency != cat.TransferEfficiency)
{ inv.TransferEfficiency = cat.TransferEfficiency; changed = true; }
if (cat.RequiresClearCoat == true && !inv.RequiresClearCoat)
{ inv.RequiresClearCoat = true; changed = true; }
return changed;
}
/// <summary>Sets a string property from the catalog only when the catalog value is non-blank and differs.</summary>
private static bool SetStrIfCatalogHas(Func<string?> get, Action<string?> set, string? catalogValue)
{
if (!string.IsNullOrWhiteSpace(catalogValue) && !string.Equals(get(), catalogValue, StringComparison.Ordinal))
{
set(catalogValue);
return true;
}
return false;
}
private static string KeyOf(PowderCatalogItem p) => $"{p.VendorName}|{p.Sku}";
/// <summary>
/// Copies feed-sourced fields from <paramref name="src"/> onto <paramref name="dest"/> and
/// returns true if anything changed. Deliberately leaves enrichment fields (SpecificGravity,
/// CoverageSqFtPerLb, TransferEfficiency, Finish) and lifecycle flags untouched — those are
/// owned by lazy TDS/AI enrichment and the discontinuation sweep, not the feed.
/// </summary>
private static bool ApplyFeedFields(PowderCatalogItem dest, PowderCatalogItem src)
{
var changed = false;
changed |= Set(() => dest.ColorName, v => dest.ColorName = v, src.ColorName);
changed |= Set(() => dest.Description, v => dest.Description = v, src.Description);
changed |= src.UnitPrice > 0 && dest.UnitPrice != src.UnitPrice && Assign(() => dest.UnitPrice = src.UnitPrice);
changed |= Set(() => dest.PriceTiersJson, v => dest.PriceTiersJson = v, src.PriceTiersJson);
changed |= Set(() => dest.ImageUrl, v => dest.ImageUrl = v, src.ImageUrl);
changed |= Set(() => dest.SdsUrl, v => dest.SdsUrl = v, src.SdsUrl);
changed |= Set(() => dest.TdsUrl, v => dest.TdsUrl = v, src.TdsUrl);
changed |= Set(() => dest.ApplicationGuideUrl, v => dest.ApplicationGuideUrl = v, src.ApplicationGuideUrl);
changed |= Set(() => dest.ProductUrl, v => dest.ProductUrl = v, src.ProductUrl);
changed |= Set(() => dest.ChemistryType, v => dest.ChemistryType = v, src.ChemistryType);
changed |= Set(() => dest.MilThickness, v => dest.MilThickness = v, src.MilThickness);
changed |= Set(() => dest.CureScheduleText, v => dest.CureScheduleText = v, src.CureScheduleText);
changed |= Set(() => dest.CureCurvesJson, v => dest.CureCurvesJson = v, src.CureCurvesJson);
changed |= src.CureTemperatureF.HasValue && dest.CureTemperatureF != src.CureTemperatureF && Assign(() => dest.CureTemperatureF = src.CureTemperatureF);
changed |= src.CureTimeMinutes.HasValue && dest.CureTimeMinutes != src.CureTimeMinutes && Assign(() => dest.CureTimeMinutes = src.CureTimeMinutes);
changed |= src.RequiresClearCoat.HasValue && dest.RequiresClearCoat != src.RequiresClearCoat && Assign(() => dest.RequiresClearCoat = src.RequiresClearCoat);
changed |= Set(() => dest.ColorFamilies, v => dest.ColorFamilies = v, src.ColorFamilies);
changed |= Set(() => dest.FormulationChanges, v => dest.FormulationChanges = v, src.FormulationChanges);
changed |= Set(() => dest.Category, v => dest.Category = v, src.Category);
changed |= Set(() => dest.Source, v => dest.Source = v, src.Source);
return changed;
}
/// <summary>
/// Sets a nullable-string property when the feed provides a non-blank value that differs.
/// Merge semantics: a blank incoming value is ignored, so a partial feed (e.g. the Prismatic
/// file import, which omits cure/chemistry) never nulls out existing data.
/// </summary>
private static bool Set(Func<string?> get, Action<string?> set, string? newValue)
{
if (string.IsNullOrWhiteSpace(newValue))
return false;
if (!string.Equals(get(), newValue, StringComparison.Ordinal))
{
set(newValue);
return true;
}
return false;
}
/// <summary>Helper so a value assignment can participate in a boolean OR chain.</summary>
private static bool Assign(Action assign)
{
assign();
return true;
}
}
@@ -60,7 +60,15 @@ 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 = "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 = "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 }, 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 },
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 }, // 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 = "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 }, 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 ──────────────────────────────────────────────────────── // ── EQUITY ────────────────────────────────────────────────────────
@@ -77,6 +85,7 @@ public partial class SeedDataService
// A credit-normal account with a debit balance appears in the Trial Balance debit column, // 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. // 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 = "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 ──────────────────────────────────────────── // ── 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 }, 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 },
@@ -141,6 +150,134 @@ public partial class SeedDataService
added++; 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; return added;
} }
} }
@@ -0,0 +1,112 @@
using System.Globalization;
using PowderCoating.Application.Constants;
using PowderCoating.Application.Interfaces;
namespace PowderCoating.Web.BackgroundServices;
/// <summary>
/// Runs the Columbia Coatings catalog sync on a schedule. Wakes hourly and triggers a full sync
/// only when the master switch (<c>ColumbiaSyncEnabled</c>) is on and the configured interval
/// (<c>ColumbiaSyncIntervalDays</c>) has elapsed since the last successful run. A full sync is
/// cheap (~25 API calls), so an hourly due-check is negligible; the actual work runs at most once
/// per interval. No-ops quietly when disabled or unconfigured.
/// </summary>
public class ColumbiaCatalogSyncBackgroundService : BackgroundService
{
private readonly IServiceScopeFactory _scopeFactory;
private readonly ILogger<ColumbiaCatalogSyncBackgroundService> _logger;
private static readonly TimeSpan CheckInterval = TimeSpan.FromHours(1);
private static readonly TimeSpan StartupDelay = TimeSpan.FromMinutes(2);
/// <summary>
/// Uses <see cref="IServiceScopeFactory"/> because a <see cref="BackgroundService"/> is a
/// singleton and the sync service / platform settings are scoped.
/// </summary>
public ColumbiaCatalogSyncBackgroundService(
IServiceScopeFactory scopeFactory,
ILogger<ColumbiaCatalogSyncBackgroundService> logger)
{
_scopeFactory = scopeFactory;
_logger = logger;
}
/// <inheritdoc />
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
_logger.LogInformation("ColumbiaCatalogSyncBackgroundService started.");
try
{
await Task.Delay(StartupDelay, stoppingToken);
while (!stoppingToken.IsCancellationRequested)
{
await RunIfDueAsync(stoppingToken);
await Task.Delay(CheckInterval, stoppingToken);
}
}
catch (OperationCanceledException)
{
// Shutting down — expected.
}
}
/// <summary>
/// Checks the enable switch and the elapsed interval, and runs a sync when due. Failures from
/// the sync itself are reported on its result (and recorded in platform settings) rather than
/// thrown, so a bad run never tears down the loop.
/// </summary>
private async Task RunIfDueAsync(CancellationToken ct)
{
using var scope = _scopeFactory.CreateScope();
var settings = scope.ServiceProvider.GetRequiredService<IPlatformSettingsService>();
if (!await settings.GetBoolAsync(ColumbiaIntegrationConstants.SettingEnabled))
return; // master switch off
var intervalDays = Math.Max(1, await settings.GetIntAsync(
ColumbiaIntegrationConstants.SettingIntervalDays,
ColumbiaIntegrationConstants.DefaultSyncIntervalDays));
if (!IsDue(await settings.GetAsync(ColumbiaIntegrationConstants.SettingLastSyncedAt), intervalDays))
return; // synced recently enough
var sync = scope.ServiceProvider.GetRequiredService<IColumbiaCatalogSyncService>();
try
{
_logger.LogInformation("Columbia scheduled sync starting (interval {Days}d).", intervalDays);
var result = await sync.RunSyncAsync(ct);
if (result.Success)
_logger.LogInformation("Columbia scheduled sync complete: {Summary}", result.Summary);
else
_logger.LogWarning("Columbia scheduled sync did not succeed: {Error}", result.ErrorMessage);
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception ex)
{
_logger.LogError(ex, "Columbia scheduled sync threw unexpectedly.");
}
}
/// <summary>
/// A sync is due when there is no recorded last-sync timestamp, or the configured number of
/// days has elapsed since it. An unparseable timestamp is treated as "due".
/// </summary>
private static bool IsDue(string? lastSyncedRaw, int intervalDays)
{
if (string.IsNullOrWhiteSpace(lastSyncedRaw))
return true;
if (!DateTime.TryParse(lastSyncedRaw, CultureInfo.InvariantCulture,
DateTimeStyles.RoundtripKind, out var lastSynced))
return true;
return DateTime.UtcNow - lastSynced.ToUniversalTime() >= TimeSpan.FromDays(intervalDays);
}
}
@@ -55,7 +55,8 @@ public class AccountsController : Controller
// GET: /Accounts // GET: /Accounts
public async Task<IActionResult> Index() public async Task<IActionResult> Index()
{ {
var accounts = await _unitOfWork.Accounts.GetAllAsync(false, a => a.ParentAccount); var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var accounts = await _unitOfWork.Accounts.FindAsync(a => a.CompanyId == companyId, false, a => a.ParentAccount);
var dtos = _mapper.Map<List<AccountListDto>>(accounts.OrderBy(a => a.AccountNumber).ToList()); var dtos = _mapper.Map<List<AccountListDto>>(accounts.OrderBy(a => a.AccountNumber).ToList());
@@ -65,6 +66,17 @@ public class AccountsController : Controller
.OrderBy(g => (int)g.Key) .OrderBy(g => (int)g.Key)
.ToList(); .ToList();
// Default-account pickers (Revenue / COGS / Inventory) — see SaveDefaultAccounts.
await PopulateDefaultAccountViewDataAsync(companyId, accounts);
// 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); return View(grouped);
} }
@@ -87,18 +99,7 @@ public class AccountsController : Controller
if (preSubType.HasValue) if (preSubType.HasValue)
{ {
dto.AccountSubType = preSubType.Value; dto.AccountSubType = preSubType.Value;
dto.AccountType = preSubType.Value switch dto.AccountType = AccountClassification.TypeForSubType(preSubType.Value);
{
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; ViewBag.Inline = inline;
if (inline) if (inline)
@@ -134,7 +135,7 @@ public class AccountsController : Controller
var currentUser = await _userManager.GetUserAsync(User); var currentUser = await _userManager.GetUserAsync(User);
// Check for duplicate account number // Check for duplicate account number
var existing = await _unitOfWork.Accounts.FindAsync(a => a.AccountNumber == dto.AccountNumber); var existing = await _unitOfWork.Accounts.FindAsync(a => a.CompanyId == currentUser!.CompanyId && a.AccountNumber == dto.AccountNumber);
if (existing.Any()) if (existing.Any())
{ {
ModelState.AddModelError(nameof(dto.AccountNumber), "An account with this number already exists."); ModelState.AddModelError(nameof(dto.AccountNumber), "An account with this number already exists.");
@@ -147,6 +148,9 @@ public class AccountsController : Controller
var account = _mapper.Map<Account>(dto); var account = _mapper.Map<Account>(dto);
account.CompanyId = currentUser!.CompanyId; account.CompanyId = currentUser!.CompanyId;
account.CreatedBy = currentUser.Email; 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.Accounts.AddAsync(account);
await _unitOfWork.CompleteAsync(); await _unitOfWork.CompleteAsync();
@@ -213,7 +217,7 @@ public class AccountsController : Controller
// Check duplicate number (excluding self) // Check duplicate number (excluding self)
var existing = await _unitOfWork.Accounts.FindAsync( var existing = await _unitOfWork.Accounts.FindAsync(
a => a.AccountNumber == dto.AccountNumber && a.Id != id); a => a.CompanyId == account.CompanyId && a.AccountNumber == dto.AccountNumber && a.Id != id);
if (existing.Any()) if (existing.Any())
{ {
ModelState.AddModelError(nameof(dto.AccountNumber), "An account with this number already exists."); ModelState.AddModelError(nameof(dto.AccountNumber), "An account with this number already exists.");
@@ -222,6 +226,9 @@ public class AccountsController : Controller
} }
_mapper.Map(dto, account); _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.UpdatedAt = DateTime.UtcNow;
account.UpdatedBy = (await _userManager.GetUserAsync(User))?.Email; account.UpdatedBy = (await _userManager.GetUserAsync(User))?.Email;
@@ -321,20 +328,49 @@ public class AccountsController : Controller
} }
/// <summary> /// <summary>
/// One-time data repair for companies whose chart of accounts was imported from QuickBooks /// Builds the Revenue / COGS / Inventory account dropdowns and the company's currently-selected
/// IIF files. QuickBooks IIF exports store credit-normal account opening balances as negative /// default account IDs for the "Default Accounts" card on the Chart of Accounts page. Revenue and
/// numbers (e.g. Revenue accounts), but the application's convention is to store all opening /// COGS are filtered by their top-level AccountType; the inventory-asset list shows all Asset
/// balances as positive amounts with the credit/debit nature implied by account type. This /// accounts (Inventory sub-type first) so a company that classified its inventory account
/// action flips negative opening balances on Revenue, Liability, and Equity accounts to their /// differently can still pick it. Reuses the already-loaded <paramref name="accounts"/> list.
/// absolute values. After running this, <see cref="RecalculateBalances"/> should be called to
/// propagate the corrected opening balances into <c>CurrentBalance</c>.
/// </summary> /// </summary>
// POST: /Accounts/FixOpeningBalanceSigns private async Task PopulateDefaultAccountViewDataAsync(int companyId, IEnumerable<Account> accounts)
// One-time fix: QB IIF imports store credit-normal accounts with negative opening balances. {
// This flips them to positive so the chart of accounts displays correctly. SelectListItem Item(Account a) => new($"{a.AccountNumber} {a.Name}", a.Id.ToString());
ViewBag.DefaultRevenueAccounts = accounts
.Where(a => a.IsActive && a.AccountType == AccountType.Revenue)
.OrderBy(a => a.AccountNumber).Select(Item).ToList();
ViewBag.DefaultCogsAccounts = accounts
.Where(a => a.IsActive && a.AccountType == AccountType.CostOfGoods)
.OrderBy(a => a.AccountNumber).Select(Item).ToList();
ViewBag.DefaultInventoryAccounts = accounts
.Where(a => a.IsActive && a.AccountType == AccountType.Asset)
.OrderByDescending(a => a.AccountSubType == AccountSubType.Inventory)
.ThenBy(a => a.AccountNumber).Select(Item).ToList();
var prefs = await _unitOfWork.CompanyPreferences
.FirstOrDefaultAsync(p => p.CompanyId == companyId && !p.IsDeleted);
ViewBag.DefaultRevenueAccountId = prefs?.DefaultRevenueAccountId;
ViewBag.DefaultCogsAccountId = prefs?.DefaultCogsAccountId;
ViewBag.DefaultInventoryAccountId = prefs?.DefaultInventoryAccountId;
}
/// <summary>
/// Saves the company's default Revenue, COGS, and Inventory accounts to <c>CompanyPreferences</c>.
/// These are used as the fallback when an item leaves its account field blank: invoice lines fall
/// back to the default Revenue account (then 4000), and new inventory/catalog items are pre-filled
/// with the default COGS/Inventory accounts. Each submitted id is validated to belong to the
/// company and to be of the expected account type before it is stored; an invalid or cleared
/// selection saves as null. CompanyAdmin-only because it affects GL routing for the whole company.
/// </summary>
// POST: /Accounts/SaveDefaultAccounts
[HttpPost, ValidateAntiForgeryToken] [HttpPost, ValidateAntiForgeryToken]
[Authorize(Policy = AppConstants.Policies.CompanyAdminOnly)] [Authorize(Policy = AppConstants.Policies.CompanyAdminOnly)]
public async Task<IActionResult> FixOpeningBalanceSigns() public async Task<IActionResult> SaveDefaultAccounts(
int? defaultRevenueAccountId, int? defaultCogsAccountId, int? defaultInventoryAccountId)
{ {
var companyId = _tenantContext.GetCurrentCompanyId(); var companyId = _tenantContext.GetCurrentCompanyId();
if (companyId == null) if (companyId == null)
@@ -345,30 +381,37 @@ public class AccountsController : Controller
try try
{ {
var accounts = await _unitOfWork.Accounts.FindAsync(a => a.CompanyId == companyId && a.IsActive); var prefs = await _unitOfWork.CompanyPreferences
int fixed_ = 0; .FirstOrDefaultAsync(p => p.CompanyId == companyId.Value && !p.IsDeleted);
foreach (var acct in accounts) if (prefs == null)
{ {
if (acct.OpeningBalance < 0 && TempData["Error"] = "Company preferences not found.";
acct.AccountType is Core.Enums.AccountType.Revenue return RedirectToAction(nameof(Index));
or Core.Enums.AccountType.Liability }
or Core.Enums.AccountType.Equity)
// 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)
{ {
acct.OpeningBalance = Math.Abs(acct.OpeningBalance); if (id == null) return null;
acct.CurrentBalance = Math.Abs(acct.CurrentBalance); var acct = await _unitOfWork.Accounts.FirstOrDefaultAsync(
await _unitOfWork.Accounts.UpdateAsync(acct); a => a.Id == id.Value && a.CompanyId == companyId.Value && a.IsActive);
fixed_++; return acct != null && allowed.Contains(acct.AccountType) ? acct.Id : null;
}
} }
prefs.DefaultRevenueAccountId = await Validate(defaultRevenueAccountId, AccountType.Revenue);
prefs.DefaultCogsAccountId = await Validate(defaultCogsAccountId, AccountType.CostOfGoods);
prefs.DefaultInventoryAccountId = await Validate(defaultInventoryAccountId, AccountType.Asset);
await _unitOfWork.CompanyPreferences.UpdateAsync(prefs);
await _unitOfWork.CompleteAsync(); await _unitOfWork.CompleteAsync();
TempData["Success"] = fixed_ > 0
? $"Fixed {fixed_} account(s) with negative opening balances. Run Recalculate Balances to update CurrentBalance." TempData["Success"] = "Default accounts saved. New items and invoice lines will use these when no account is chosen.";
: "No accounts needed fixing — all opening balances already have the correct sign.";
} }
catch (Exception ex) catch (Exception ex)
{ {
_logger.LogError(ex, "Error fixing opening balance signs for company {CompanyId}", companyId); _logger.LogError(ex, "Error saving default accounts for company {CompanyId}", companyId);
TempData["Error"] = "An error occurred while fixing opening balances."; TempData["Error"] = "An error occurred while saving the default accounts.";
} }
return RedirectToAction(nameof(Index)); return RedirectToAction(nameof(Index));
@@ -439,7 +482,7 @@ public class AccountsController : Controller
public async Task<IActionResult> YearEndClose() public async Task<IActionResult> YearEndClose()
{ {
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0; var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var history = (await _unitOfWork.YearEndCloses.FindAsync(y => true, false, y => y.JournalEntry)) var history = (await _unitOfWork.YearEndCloses.FindAsync(y => y.CompanyId == companyId, false, y => y.JournalEntry))
.OrderByDescending(y => y.ClosedYear) .OrderByDescending(y => y.ClosedYear)
.ToList(); .ToList();
@@ -464,7 +507,7 @@ public class AccountsController : Controller
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0; var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
// Idempotency check // Idempotency check
var existing = (await _unitOfWork.YearEndCloses.FindAsync(y => y.ClosedYear == year)).FirstOrDefault(); var existing = (await _unitOfWork.YearEndCloses.FindAsync(y => y.CompanyId == companyId && y.ClosedYear == year)).FirstOrDefault();
if (existing != null) if (existing != null)
{ {
TempData["Error"] = $"{year} has already been closed (JE {existing.JournalEntryId})."; TempData["Error"] = $"{year} has already been closed (JE {existing.JournalEntryId}).";
@@ -472,7 +515,7 @@ public class AccountsController : Controller
} }
// Load all active accounts with balances // Load all active accounts with balances
var accounts = (await _unitOfWork.Accounts.FindAsync(a => a.IsActive)).ToList(); var accounts = (await _unitOfWork.Accounts.FindAsync(a => a.CompanyId == companyId && a.IsActive)).ToList();
var revenueAccounts = accounts.Where(a => a.AccountType == AccountType.Revenue).ToList(); var revenueAccounts = accounts.Where(a => a.AccountType == AccountType.Revenue).ToList();
var expenseAccounts = accounts.Where(a => var expenseAccounts = accounts.Where(a =>
@@ -616,7 +659,8 @@ public class AccountsController : Controller
/// </summary> /// </summary>
private async Task PopulateDropdownsAsync(int? excludeId = null) private async Task PopulateDropdownsAsync(int? excludeId = null)
{ {
var allAccounts = await _unitOfWork.Accounts.FindAsync(a => excludeId == null || a.Id != excludeId.Value); var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var allAccounts = await _unitOfWork.Accounts.FindAsync(a => a.CompanyId == companyId && (excludeId == null || a.Id != excludeId.Value));
ViewBag.ParentAccounts = allAccounts ViewBag.ParentAccounts = allAccounts
.OrderBy(a => a.AccountNumber) .OrderBy(a => a.AccountNumber)
@@ -65,7 +65,7 @@ public class AiQuickQuoteController : Controller
try try
{ {
var powders = await _unitOfWork.InventoryItems.FindAsync(i => var powders = await _unitOfWork.InventoryItems.FindAsync(i =>
i.Category != null && i.Category.ToLower().Contains("powder") && i.UnitCost > 0); i.CompanyId == currentUser.CompanyId && i.Category != null && i.Category.ToLower().Contains("powder") && i.UnitCost > 0);
if (powders.Any()) if (powders.Any())
avgPowderCost = powders.Average(p => p.UnitCost); avgPowderCost = powders.Average(p => p.UnitCost);
} }
@@ -180,7 +180,7 @@ public class AiQuickQuoteController : Controller
var context = new CompanyAiContext { ProfileText = costs.AiContextProfile }; var context = new CompanyAiContext { ProfileText = costs.AiContextProfile };
var predictions = await _unitOfWork.AiItemPredictions.FindAsync( var predictions = await _unitOfWork.AiItemPredictions.FindAsync(
p => !p.UserOverrodeEstimate && p.PredictedSurfaceAreaSqFt > 0 && p.PredictedUnitPrice > 0); p => p.CompanyId == companyId && !p.UserOverrodeEstimate && p.PredictedSurfaceAreaSqFt > 0 && p.PredictedUnitPrice > 0);
context.AcceptedExamples = predictions context.AcceptedExamples = predictions
.OrderByDescending(p => p.CreatedAt) .OrderByDescending(p => p.CreatedAt)
@@ -213,8 +213,9 @@ public class AiQuickQuoteController : Controller
{ {
try try
{ {
var companyId = (await _userManager.GetUserAsync(User))?.CompanyId ?? 0;
var inventory = await _unitOfWork.InventoryItems.FindAsync( var inventory = await _unitOfWork.InventoryItems.FindAsync(
i => i.IsActive, i => i.CompanyId == companyId && i.IsActive,
false, false,
i => i.InventoryCategory); i => i.InventoryCategory);
@@ -267,7 +268,7 @@ public class AiQuickQuoteController : Controller
private async Task<Customer> GetOrCreateWalkInCustomerAsync(int companyId) private async Task<Customer> GetOrCreateWalkInCustomerAsync(int companyId)
{ {
var existing = (await _unitOfWork.Customers.FindAsync( var existing = (await _unitOfWork.Customers.FindAsync(
c => c.CompanyName == "Walk-In / Phone" && c.IsActive)) c => c.CompanyId == companyId && c.CompanyName == "Walk-In / Phone" && c.IsActive))
.FirstOrDefault(); .FirstOrDefault();
if (existing != null) return existing; if (existing != null) return existing;
@@ -8,6 +8,7 @@ using PowderCoating.Core.Entities;
using PowderCoating.Core.Enums; using PowderCoating.Core.Enums;
using PowderCoating.Core.Interfaces; using PowderCoating.Core.Interfaces;
using PowderCoating.Shared.Constants; using PowderCoating.Shared.Constants;
using PowderCoating.Web.Helpers;
namespace PowderCoating.Web.Controllers; namespace PowderCoating.Web.Controllers;
@@ -73,6 +74,13 @@ public class BankReconciliationsController : Controller
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0; 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 // Set beginning balance from last completed reconciliation for this account, or 0
var lastCompleted = (await _unitOfWork.BankReconciliations.FindAsync( var lastCompleted = (await _unitOfWork.BankReconciliations.FindAsync(
br => br.CompanyId == companyId br => br.CompanyId == companyId
@@ -365,11 +373,14 @@ public class BankReconciliationsController : Controller
private async Task PopulateAccountDropdownAsync() 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( var accounts = await _unitOfWork.Accounts.FindAsync(
a => a.IsActive a => a.CompanyId == companyId && a.IsActive
&& (a.AccountSubType == AccountSubType.Checking && (a.AccountType == AccountType.Asset
|| a.AccountSubType == AccountSubType.Savings || a.AccountType == AccountType.Liability));
|| a.AccountSubType == AccountSubType.Cash));
ViewBag.AccountSelectList = accounts ViewBag.AccountSelectList = accounts
.OrderBy(a => a.AccountNumber) .OrderBy(a => a.AccountNumber)
@@ -202,14 +202,14 @@ public class BillsController : Controller
} }
var apAccount = await _unitOfWork.Accounts.FirstOrDefaultAsync( var apAccount = await _unitOfWork.Accounts.FirstOrDefaultAsync(
a => a.AccountSubType == AccountSubType.AccountsPayable); a => a.CompanyId == po.CompanyId && a.AccountSubType == AccountSubType.AccountsPayable);
// Vendor default expense account, fall back to first expense/COGS account // Vendor default expense account, fall back to first expense/COGS account
int? defaultExpenseAccountId = po.Vendor?.DefaultExpenseAccountId; int? defaultExpenseAccountId = po.Vendor?.DefaultExpenseAccountId;
if (!defaultExpenseAccountId.HasValue) if (!defaultExpenseAccountId.HasValue)
{ {
var fallbackAccount = await _unitOfWork.Accounts.FirstOrDefaultAsync( var fallbackAccount = await _unitOfWork.Accounts.FirstOrDefaultAsync(
a => a.IsActive && (a.AccountType == AccountType.Expense || a.AccountType == AccountType.CostOfGoods)); a => a.CompanyId == po.CompanyId && a.IsActive && (a.AccountType == AccountType.Expense || a.AccountType == AccountType.CostOfGoods));
defaultExpenseAccountId = fallbackAccount?.Id; defaultExpenseAccountId = fallbackAccount?.Id;
} }
@@ -272,8 +272,9 @@ public class BillsController : Controller
}; };
// Pre-fill AP account // Pre-fill AP account
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var apAccount = await _unitOfWork.Accounts.FirstOrDefaultAsync( var apAccount = await _unitOfWork.Accounts.FirstOrDefaultAsync(
a => a.AccountSubType == AccountSubType.AccountsPayable); a => a.CompanyId == companyId && a.AccountSubType == AccountSubType.AccountsPayable);
dto.APAccountId = apAccount?.Id ?? 0; dto.APAccountId = apAccount?.Id ?? 0;
// Pre-fill default expense account for vendor // Pre-fill default expense account for vendor
@@ -339,6 +340,16 @@ 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? bill = null;
// Bill entity, PO back-reference, and optional immediate payment all commit // Bill entity, PO back-reference, and optional immediate payment all commit
@@ -451,11 +462,12 @@ public class BillsController : Controller
var dto = _mapper.Map<BillDto>(bill); var dto = _mapper.Map<BillDto>(bill);
// Payment form defaults // 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( var bankAccounts = (await _unitOfWork.Accounts.FindAsync(
a => a.AccountSubType == AccountSubType.Cash || a => a.CompanyId == bill.CompanyId &&
a.AccountSubType == AccountSubType.Checking || (a.AccountType == AccountType.Asset ||
a.AccountSubType == AccountSubType.Savings || a.AccountType == AccountType.Liability)))
a.AccountSubType == AccountSubType.CreditCard))
.OrderBy(a => a.AccountNumber) .OrderBy(a => a.AccountNumber)
.ToList(); .ToList();
@@ -716,6 +728,14 @@ public class BillsController : Controller
return RedirectToAction(nameof(Details), new { id = dto.BillId }); 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 currentUser = await _userManager.GetUserAsync(User);
var payment = _mapper.Map<BillPayment>(dto); var payment = _mapper.Map<BillPayment>(dto);
@@ -841,6 +861,13 @@ public class BillsController : Controller
var payment = await _unitOfWork.BillPayments.GetByIdAsync(dto.PaymentId); var payment = await _unitOfWork.BillPayments.GetByIdAsync(dto.PaymentId);
if (payment == null) return NotFound(); 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); var currentUser = await _userManager.GetUserAsync(User);
// If the bank account changed, reverse the old balance entry and apply the new one // If the bank account changed, reverse the old balance entry and apply the new one
@@ -1076,7 +1103,8 @@ public class BillsController : Controller
return Json(new { success = false, error = "File must be under 10 MB." }); return Json(new { success = false, error = "File must be under 10 MB." });
// Load expense accounts for matching // Load expense accounts for matching
var allAccounts = await _unitOfWork.Accounts.FindAsync(a => a.IsActive); var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var allAccounts = await _unitOfWork.Accounts.FindAsync(a => a.CompanyId == companyId && a.IsActive);
var expenseAccounts = allAccounts var expenseAccounts = allAccounts
.Where(a => a.AccountType == AccountType.Expense || .Where(a => a.AccountType == AccountType.Expense ||
a.AccountType == AccountType.CostOfGoods || a.AccountType == AccountType.CostOfGoods ||
@@ -1096,7 +1124,6 @@ public class BillsController : Controller
var imageBytes = ms.ToArray(); var imageBytes = ms.ToArray();
var result = await _accountingAi.ScanReceiptAsync(imageBytes, receiptImage.ContentType, expenseAccounts); 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 ?? ""; var userId = User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value ?? "";
await _usageLogger.LogAsync(companyId, userId, AppConstants.AiFeatures.ReceiptScan, inputLength: (int)receiptImage.Length); await _usageLogger.LogAsync(companyId, userId, AppConstants.AiFeatures.ReceiptScan, inputLength: (int)receiptImage.Length);
return Json(result); return Json(result);
@@ -1123,7 +1150,8 @@ public class BillsController : Controller
// Load expense accounts if not supplied // Load expense accounts if not supplied
if (!request.AvailableAccounts.Any()) if (!request.AvailableAccounts.Any())
{ {
var allAccounts = await _unitOfWork.Accounts.FindAsync(a => a.IsActive); var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var allAccounts = await _unitOfWork.Accounts.FindAsync(a => a.CompanyId == companyId && a.IsActive);
request.AvailableAccounts = allAccounts request.AvailableAccounts = allAccounts
.Where(a => a.AccountType == AccountType.Expense || .Where(a => a.AccountType == AccountType.Expense ||
a.AccountType == AccountType.CostOfGoods || a.AccountType == AccountType.CostOfGoods ||
@@ -1170,7 +1198,7 @@ public class BillsController : Controller
var companyId = int.TryParse(User.FindFirst("CompanyId")?.Value, out var cid) ? cid : 0; var companyId = int.TryParse(User.FindFirst("CompanyId")?.Value, out var cid) ? cid : 0;
var cutoff = DateTime.Today.AddMonths(-12); var cutoff = DateTime.Today.AddMonths(-12);
var bills = (await _unitOfWork.Bills.GetAllAsync(false, b => b.Vendor)) var bills = (await _unitOfWork.Bills.FindAsync(b => b.CompanyId == companyId, false, b => b.Vendor))
.Where(b => b.Status != BillStatus.Voided && b.BillDate >= cutoff) .Where(b => b.Status != BillStatus.Voided && b.BillDate >= cutoff)
.ToList(); .ToList();
@@ -30,7 +30,8 @@ public class BudgetsController : Controller
/// <summary>Lists all budgets for the current company ordered by fiscal year descending.</summary> /// <summary>Lists all budgets for the current company ordered by fiscal year descending.</summary>
public async Task<IActionResult> Index() public async Task<IActionResult> Index()
{ {
var budgets = (await _unitOfWork.Budgets.FindAsync(b => true, false, b => b.Lines)) var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var budgets = (await _unitOfWork.Budgets.FindAsync(b => b.CompanyId == companyId, false, b => b.Lines))
.OrderByDescending(b => b.FiscalYear) .OrderByDescending(b => b.FiscalYear)
.ThenBy(b => b.Name) .ThenBy(b => b.Name)
.ToList(); .ToList();
@@ -246,15 +247,16 @@ public class BudgetsController : Controller
private async Task<List<Account>> GetBudgetableAccountsAsync() private async Task<List<Account>> GetBudgetableAccountsAsync()
{ {
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var accounts = await _unitOfWork.Accounts.FindAsync( var accounts = await _unitOfWork.Accounts.FindAsync(
a => a.IsActive && (a.AccountType == AccountType.Revenue || a.AccountType == AccountType.Expense)); a => a.CompanyId == companyId && a.IsActive && (a.AccountType == AccountType.Revenue || a.AccountType == AccountType.Expense));
return accounts.OrderBy(a => a.AccountNumber).ToList(); return accounts.OrderBy(a => a.AccountNumber).ToList();
} }
private async Task ClearDefaultFlagAsync(int companyId, int fiscalYear, int? excludeId) private async Task ClearDefaultFlagAsync(int companyId, int fiscalYear, int? excludeId)
{ {
var others = await _unitOfWork.Budgets.FindAsync( var others = await _unitOfWork.Budgets.FindAsync(
b => b.IsDefault && b.FiscalYear == fiscalYear && b.Id != (excludeId ?? 0)); b => b.CompanyId == companyId && b.IsDefault && b.FiscalYear == fiscalYear && b.Id != (excludeId ?? 0));
foreach (var b in others) foreach (var b in others)
{ {
b.IsDefault = false; b.IsDefault = false;
@@ -208,10 +208,16 @@ namespace PowderCoating.Web.Controllers
var useMetric = await _tenantContext.UseMetricSystemAsync(); var useMetric = await _tenantContext.UseMetricSystemAsync();
ViewBag.AreaUnit = _measurementService.GetAreaUnitLabel(useMetric); ViewBag.AreaUnit = _measurementService.GetAreaUnitLabel(useMetric);
// Pre-fill the GL account dropdowns from the company's configured defaults.
var prefs = await _unitOfWork.CompanyPreferences
.FirstOrDefaultAsync(p => p.CompanyId == companyId && !p.IsDeleted);
var model = new CreateCatalogItemDto var model = new CreateCatalogItemDto
{ {
CategoryId = categoryId ?? 0, CategoryId = categoryId ?? 0,
DisplayOrder = 0 DisplayOrder = 0,
RevenueAccountId = prefs?.DefaultRevenueAccountId,
CogsAccountId = prefs?.DefaultCogsAccountId
}; };
return View(model); return View(model);
@@ -494,7 +500,8 @@ namespace PowderCoating.Web.Controllers
{ {
try try
{ {
var items = await _unitOfWork.CatalogItems.FindAsync(i => i.CategoryId == categoryId && i.IsActive); var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var items = await _unitOfWork.CatalogItems.FindAsync(i => i.CompanyId == companyId && i.CategoryId == categoryId && i.IsActive);
var itemDtos = items var itemDtos = items
.OrderBy(i => i.DisplayOrder) .OrderBy(i => i.DisplayOrder)
@@ -535,8 +542,9 @@ namespace PowderCoating.Web.Controllers
{ {
try try
{ {
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var items = await _unitOfWork.CatalogItems.FindAsync( var items = await _unitOfWork.CatalogItems.FindAsync(
i => i.IsMerchandise && i.IsActive, false, i => i.Category); i => i.CompanyId == companyId && i.IsMerchandise && i.IsActive, false, i => i.Category);
var result = items var result = items
.OrderBy(i => i.Category.Name) .OrderBy(i => i.Category.Name)
@@ -670,7 +678,8 @@ namespace PowderCoating.Web.Controllers
return; return;
} }
var accounts = await _unitOfWork.Accounts.FindAsync(a => a.IsActive); var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var accounts = await _unitOfWork.Accounts.FindAsync(a => a.CompanyId == companyId && a.IsActive);
var revenueAccounts = accounts var revenueAccounts = accounts
.Where(a => a.AccountType == PowderCoating.Core.Enums.AccountType.Revenue) .Where(a => a.AccountType == PowderCoating.Core.Enums.AccountType.Revenue)
@@ -686,6 +695,13 @@ namespace PowderCoating.Web.Controllers
ViewBag.RevenueAccounts = revenueAccounts; ViewBag.RevenueAccounts = revenueAccounts;
ViewBag.CogsAccounts = cogsAccounts; 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> /// <summary>
@@ -898,7 +914,7 @@ namespace PowderCoating.Web.Controllers
// Get all active catalog items with their categories // Get all active catalog items with their categories
var items = await _unitOfWork.CatalogItems.FindAsync( var items = await _unitOfWork.CatalogItems.FindAsync(
ci => ci.IsActive, ci => ci.CompanyId == currentUser.CompanyId && ci.IsActive,
false, false,
ci => ci.Category ci => ci.Category
); );
@@ -953,7 +969,7 @@ namespace PowderCoating.Web.Controllers
r => r.CompanyId == currentUser.CompanyId); r => r.CompanyId == currentUser.CompanyId);
var report = existing.OrderByDescending(r => r.RunAt).FirstOrDefault(); var report = existing.OrderByDescending(r => r.RunAt).FirstOrDefault();
var pricedItems = await _unitOfWork.CatalogItems.FindAsync(ci => ci.IsActive && ci.DefaultPrice > 0); var pricedItems = await _unitOfWork.CatalogItems.FindAsync(ci => ci.CompanyId == currentUser.CompanyId && ci.IsActive && ci.DefaultPrice > 0);
ViewBag.ActiveItemCount = pricedItems.Count(); ViewBag.ActiveItemCount = pricedItems.Count();
if (report != null) if (report != null)
@@ -1037,7 +1053,7 @@ namespace PowderCoating.Web.Controllers
// Load active catalog items with a real price — skip $0 items (placeholders, // Load active catalog items with a real price — skip $0 items (placeholders,
// category headers, etc.) since there's no pricing to evaluate. // category headers, etc.) since there's no pricing to evaluate.
var items = (await _unitOfWork.CatalogItems.FindAsync( var items = (await _unitOfWork.CatalogItems.FindAsync(
ci => ci.IsActive && ci.DefaultPrice > 0, false, ci => ci.Category)).ToList(); ci => ci.CompanyId == currentUser.CompanyId && ci.IsActive && ci.DefaultPrice > 0, false, ci => ci.Category)).ToList();
if (items.Count == 0) if (items.Count == 0)
{ {
@@ -754,6 +754,69 @@ public class CompaniesController : Controller
} }
} }
/// <summary>
/// One-time data repair for a company whose chart of accounts was imported from QuickBooks
/// IIF files. QuickBooks stores credit-normal account opening balances as negative numbers
/// (e.g. Revenue, Liability, Equity), but this app's convention is positive opening balances
/// with the debit/credit nature implied by account type. This flips negative opening balances
/// on Revenue/Liability/Equity accounts to their absolute values so the chart of accounts
/// reads correctly. Afterward, Recalculate Balances (on the Chart of Accounts page) should be
/// run to propagate the corrected opening balances into CurrentBalance.
/// <para>
/// This is a SuperAdmin-only platform tool — it operates on the target company identified by
/// <paramref name="id"/> (not the caller's tenant), so it uses <c>ignoreQueryFilters</c> to
/// reach across the multi-tenancy boundary. It was deliberately moved here from the company
/// Chart of Accounts page so normal company admins can't see or trigger it.
/// </para>
/// </summary>
// POST: Companies/FixOpeningBalanceSigns/5
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> FixOpeningBalanceSigns(int id)
{
var company = await _unitOfWork.Companies.GetByIdAsync(id, ignoreQueryFilters: true);
if (company == null)
{
TempData["Error"] = "Company not found.";
return RedirectToAction(nameof(Index));
}
try
{
// Explicit CompanyId predicate + ignoreQueryFilters: SuperAdmin acts on another
// tenant, so the global multi-tenancy filter must be bypassed but scoping kept tight.
var accounts = await _unitOfWork.Accounts.FindAsync(
a => a.CompanyId == id && a.IsActive, ignoreQueryFilters: true);
int fixedCount = 0;
foreach (var acct in accounts)
{
if (acct.OpeningBalance < 0 &&
acct.AccountType is Core.Enums.AccountType.Revenue
or Core.Enums.AccountType.Liability
or Core.Enums.AccountType.Equity)
{
acct.OpeningBalance = Math.Abs(acct.OpeningBalance);
acct.CurrentBalance = Math.Abs(acct.CurrentBalance);
await _unitOfWork.Accounts.UpdateAsync(acct);
fixedCount++;
}
}
await _unitOfWork.CompleteAsync();
TempData[fixedCount > 0 ? "Success" : "Info"] = fixedCount > 0
? $"Fixed {fixedCount} account(s) with negative opening balances for '{company.CompanyName}'. Run Recalculate Balances on the company's Chart of Accounts to update CurrentBalance."
: $"No accounts needed fixing for '{company.CompanyName}' — all opening balances already have the correct sign.";
}
catch (Exception ex)
{
_logger.LogError(ex, "Error fixing opening balance signs for company {CompanyId}", id);
TempData["Error"] = "An error occurred while fixing opening balances.";
}
return RedirectToAction(nameof(Details), new { id });
}
/// <summary> /// <summary>
/// Renders the form for adding an additional CompanyAdmin user to an existing company. /// Renders the form for adding an additional CompanyAdmin user to an existing company.
/// Used when a company needs more than one admin or when the original admin's account must /// Used when a company needs more than one admin or when the original admin's account must
@@ -990,7 +990,7 @@ public class CompanySettingsController : Controller
// Add job counts // Add job counts
foreach (var dto in dtos) foreach (var dto in dtos)
{ {
dto.JobCount = await _unitOfWork.Jobs.CountAsync(j => j.JobStatusId == dto.Id); dto.JobCount = await _unitOfWork.Jobs.CountAsync(j => j.CompanyId == companyId && j.JobStatusId == dto.Id);
} }
return Json(dtos); return Json(dtos);
@@ -1023,7 +1023,7 @@ public class CompanySettingsController : Controller
// Check if status code already exists for this company // Check if status code already exists for this company
var exists = await _unitOfWork.JobStatusLookups var exists = await _unitOfWork.JobStatusLookups
.AnyAsync(s => s.StatusCode == dto.StatusCode); .AnyAsync(s => s.CompanyId == companyId.Value && s.StatusCode == dto.StatusCode);
if (exists) if (exists)
return Json(new { success = false, message = "Status code already 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" }); return Json(new { success = false, message = "Cannot delete system-defined status" });
// Check if status is in use // Check if status is in use
var inUse = await _unitOfWork.Jobs.AnyAsync(j => j.JobStatusId == id); var inUse = await _unitOfWork.Jobs.AnyAsync(j => j.CompanyId == status.CompanyId && j.JobStatusId == id);
if (inUse) if (inUse)
return Json(new { success = false, message = "Status is in use and cannot be deleted" }); 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 // Add job counts
foreach (var dto in dtos) foreach (var dto in dtos)
{ {
dto.JobCount = await _unitOfWork.Jobs.CountAsync(j => j.JobPriorityId == dto.Id); dto.JobCount = await _unitOfWork.Jobs.CountAsync(j => j.CompanyId == companyId && j.JobPriorityId == dto.Id);
} }
return Json(dtos); return Json(dtos);
@@ -1216,7 +1216,7 @@ public class CompanySettingsController : Controller
// Check if priority code already exists for this company // Check if priority code already exists for this company
var exists = await _unitOfWork.JobPriorityLookups var exists = await _unitOfWork.JobPriorityLookups
.AnyAsync(p => p.PriorityCode == dto.PriorityCode); .AnyAsync(p => p.CompanyId == companyId.Value && p.PriorityCode == dto.PriorityCode);
if (exists) if (exists)
return Json(new { success = false, message = "Priority code already 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" }); return Json(new { success = false, message = "Cannot delete system-defined priority" });
// Check if priority is in use // Check if priority is in use
var inUse = await _unitOfWork.Jobs.AnyAsync(j => j.JobPriorityId == id); var inUse = await _unitOfWork.Jobs.AnyAsync(j => j.CompanyId == priority.CompanyId && j.JobPriorityId == id);
if (inUse) if (inUse)
return Json(new { success = false, message = "Priority is in use and cannot be deleted" }); 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 // Add quote counts
foreach (var dto in dtos) foreach (var dto in dtos)
{ {
dto.QuoteCount = await _unitOfWork.Quotes.CountAsync(q => q.QuoteStatusId == dto.Id); dto.QuoteCount = await _unitOfWork.Quotes.CountAsync(q => q.CompanyId == companyId && q.QuoteStatusId == dto.Id);
} }
return Json(dtos); return Json(dtos);
@@ -1403,7 +1403,7 @@ public class CompanySettingsController : Controller
// Check if status code already exists for this company // Check if status code already exists for this company
var exists = await _unitOfWork.QuoteStatusLookups var exists = await _unitOfWork.QuoteStatusLookups
.AnyAsync(s => s.StatusCode == dto.StatusCode); .AnyAsync(s => s.CompanyId == companyId.Value && s.StatusCode == dto.StatusCode);
if (exists) if (exists)
return Json(new { success = false, message = "Status code already exists" }); return Json(new { success = false, message = "Status code already exists" });
@@ -1411,7 +1411,7 @@ public class CompanySettingsController : Controller
if (dto.IsApprovedStatus) if (dto.IsApprovedStatus)
{ {
var hasApproved = await _unitOfWork.QuoteStatusLookups var hasApproved = await _unitOfWork.QuoteStatusLookups
.AnyAsync(s => s.IsApprovedStatus); .AnyAsync(s => s.CompanyId == companyId.Value && s.IsApprovedStatus);
if (hasApproved) if (hasApproved)
return Json(new { success = false, message = "Only one status can be marked as 'Approved'" }); 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) if (dto.IsConvertedStatus)
{ {
var hasConverted = await _unitOfWork.QuoteStatusLookups var hasConverted = await _unitOfWork.QuoteStatusLookups
.AnyAsync(s => s.IsConvertedStatus); .AnyAsync(s => s.CompanyId == companyId.Value && s.IsConvertedStatus);
if (hasConverted) if (hasConverted)
return Json(new { success = false, message = "Only one status can be marked as 'Converted'" }); 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) if (dto.IsApprovedStatus && !status.IsApprovedStatus)
{ {
var hasApproved = await _unitOfWork.QuoteStatusLookups var hasApproved = await _unitOfWork.QuoteStatusLookups
.AnyAsync(s => s.Id != dto.Id && s.IsApprovedStatus); .AnyAsync(s => s.CompanyId == status.CompanyId && s.Id != dto.Id && s.IsApprovedStatus);
if (hasApproved) if (hasApproved)
return Json(new { success = false, message = "Only one status can be marked as 'Approved'" }); 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) if (dto.IsConvertedStatus && !status.IsConvertedStatus)
{ {
var hasConverted = await _unitOfWork.QuoteStatusLookups var hasConverted = await _unitOfWork.QuoteStatusLookups
.AnyAsync(s => s.Id != dto.Id && s.IsConvertedStatus); .AnyAsync(s => s.CompanyId == status.CompanyId && s.Id != dto.Id && s.IsConvertedStatus);
if (hasConverted) if (hasConverted)
return Json(new { success = false, message = "Only one status can be marked as 'Converted'" }); 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" }); return Json(new { success = false, message = "Cannot delete system-defined status" });
// Check if status is in use // Check if status is in use
var inUse = await _unitOfWork.Quotes.AnyAsync(q => q.QuoteStatusId == id); var inUse = await _unitOfWork.Quotes.AnyAsync(q => q.CompanyId == status.CompanyId && q.QuoteStatusId == id);
if (inUse) if (inUse)
return Json(new { success = false, message = "Status is in use and cannot be deleted" }); 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 // Add appointment counts
foreach (var dto in dtos) foreach (var dto in dtos)
{ {
dto.AppointmentCount = await _unitOfWork.Appointments.CountAsync(a => a.AppointmentTypeId == dto.Id); dto.AppointmentCount = await _unitOfWork.Appointments.CountAsync(a => a.CompanyId == companyId && a.AppointmentTypeId == dto.Id);
} }
return Json(dtos); return Json(dtos);
@@ -1941,7 +1941,7 @@ public class CompanySettingsController : Controller
// Check if type code already exists for this company // Check if type code already exists for this company
var exists = await _unitOfWork.AppointmentTypeLookups var exists = await _unitOfWork.AppointmentTypeLookups
.AnyAsync(t => t.TypeCode == dto.TypeCode); .AnyAsync(t => t.CompanyId == companyId.Value && t.TypeCode == dto.TypeCode);
if (exists) if (exists)
return Json(new { success = false, message = "Type code already 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" }); return Json(new { success = false, message = "Cannot delete system-defined type" });
// Check if type is in use // Check if type is in use
var inUse = await _unitOfWork.Appointments.AnyAsync(a => a.AppointmentTypeId == id); var inUse = await _unitOfWork.Appointments.AnyAsync(a => a.CompanyId == type.CompanyId && a.AppointmentTypeId == id);
if (inUse) if (inUse)
return Json(new { success = false, message = "Type is in use and cannot be deleted" }); 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 // Add item counts
foreach (var dto in dtos) foreach (var dto in dtos)
{ {
dto.ItemCount = await _unitOfWork.InventoryItems.CountAsync(i => i.InventoryCategoryId == dto.Id); dto.ItemCount = await _unitOfWork.InventoryItems.CountAsync(i => i.CompanyId == companyId && i.InventoryCategoryId == dto.Id);
} }
return Json(dtos); return Json(dtos);
@@ -2127,7 +2127,7 @@ public class CompanySettingsController : Controller
// Check if category code already exists for this company // Check if category code already exists for this company
var exists = await _unitOfWork.InventoryCategoryLookups var exists = await _unitOfWork.InventoryCategoryLookups
.AnyAsync(c => c.CategoryCode == dto.CategoryCode); .AnyAsync(c => c.CompanyId == companyId.Value && c.CategoryCode == dto.CategoryCode);
if (exists) if (exists)
return Json(new { success = false, message = "Category code already 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" }); return Json(new { success = false, message = "Category not found" });
// Check if category is in use // Check if category is in use
var inUse = await _unitOfWork.InventoryItems.AnyAsync(i => i.InventoryCategoryId == id); var inUse = await _unitOfWork.InventoryItems.AnyAsync(i => i.CompanyId == category.CompanyId && i.InventoryCategoryId == id);
if (inUse) if (inUse)
return Json(new { success = false, message = "Category is in use and cannot be deleted" }); 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." }); return Json(new { success = false, message = "Oven not found." });
// Check if any quotes reference this oven // Check if any quotes reference this oven
var usageCount = await _unitOfWork.Quotes.CountAsync(q => q.OvenCostId == id); var usageCount = await _unitOfWork.Quotes.CountAsync(q => q.CompanyId == companyId.Value && q.OvenCostId == id);
if (usageCount > 0) if (usageCount > 0)
return Json(new { success = false, message = $"Cannot delete: {usageCount} quote(s) reference this oven. Deactivate it instead." }); return Json(new { success = false, message = $"Cannot delete: {usageCount} quote(s) reference this oven. Deactivate it instead." });
@@ -47,8 +47,9 @@ public class CreditMemosController : Controller
[HttpGet] [HttpGet]
public async Task<IActionResult> Index(string? status, string? search) public async Task<IActionResult> Index(string? status, string? search)
{ {
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var memos = await _unitOfWork.CreditMemos.FindAsync( var memos = await _unitOfWork.CreditMemos.FindAsync(
m => true, false, m => m.CompanyId == companyId, false,
m => m.Customer); m => m.Customer);
if (!string.IsNullOrWhiteSpace(search)) if (!string.IsNullOrWhiteSpace(search))
@@ -177,6 +178,13 @@ public class CreditMemosController : Controller
await _unitOfWork.CompleteAsync(); 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)}."; TempData["Success"] = $"Credit memo {memoNumber} for {vm.Amount:C} issued to {DisplayName(customer)}.";
return RedirectToAction(nameof(Details), new { id = memo.Id }); return RedirectToAction(nameof(Details), new { id = memo.Id });
} }
@@ -252,18 +260,14 @@ public class CreditMemosController : Controller
await _unitOfWork.Invoices.UpdateAsync(invoice); await _unitOfWork.Invoices.UpdateAsync(invoice);
} }
// GL: DR 4950 Sales Discounts (contra-revenue) / CR AR. // GL: applying the credit relieves the liability — DR Customer Credits (2350) / CR AR.
// The dynamic report computation attributes credit memo applications to both // The contra-revenue (Sales Discounts) was recognized when the credit was issued.
// accounts already; this call keeps Account.CurrentBalance in sync for // Keeps Account.CurrentBalance in sync for RecalculateAllAsync and direct readers.
// RecalculateAllAsync and any tools that read it directly.
var arAcct = await _unitOfWork.Accounts.FirstOrDefaultAsync( var arAcct = await _unitOfWork.Accounts.FirstOrDefaultAsync(
a => a.AccountSubType == AccountSubType.AccountsReceivable && a.IsActive); a => a.CompanyId == invoice.CompanyId && a.AccountSubType == AccountSubType.AccountsReceivable && a.IsActive);
var discountAcct = await _unitOfWork.Accounts.FirstOrDefaultAsync( var customerCreditsAcct = await _unitOfWork.Accounts.FirstOrDefaultAsync(
a => a.AccountNumber == "4950" && a.IsActive) a => a.CompanyId == invoice.CompanyId && a.AccountNumber == "2350" && a.IsActive);
?? await _unitOfWork.Accounts.FirstOrDefaultAsync( await _accountBalanceService.DebitAsync(customerCreditsAcct?.Id, applyAmount);
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 _accountBalanceService.CreditAsync(arAcct?.Id, applyAmount);
await _unitOfWork.CompleteAsync(); await _unitOfWork.CompleteAsync();
@@ -308,6 +312,15 @@ public class CreditMemosController : Controller
await _unitOfWork.Customers.UpdateAsync(memo.Customer); 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(); await _unitOfWork.CompleteAsync();
TempData["Success"] = "Credit memo voided. Unapplied balance reversed from customer credit."; TempData["Success"] = "Credit memo voided. Unapplied balance reversed from customer credit.";
return RedirectToAction(nameof(Details), new { id }); return RedirectToAction(nameof(Details), new { id });
@@ -1411,7 +1411,8 @@ public class CustomersController : Controller
/// </summary> /// </summary>
private async Task PopulatePricingTiersAsync() private async Task PopulatePricingTiersAsync()
{ {
var tiers = await _unitOfWork.PricingTiers.FindAsync(t => t.IsActive); var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var tiers = await _unitOfWork.PricingTiers.FindAsync(t => t.CompanyId == companyId && t.IsActive);
ViewBag.PricingTiers = tiers ViewBag.PricingTiers = tiers
.OrderBy(t => t.TierName) .OrderBy(t => t.TierName)
.Select(t => new SelectListItem .Select(t => new SelectListItem
@@ -25,6 +25,7 @@ public class DashboardController : Controller
private readonly ICompanyConfigHealthService _configHealth; private readonly ICompanyConfigHealthService _configHealth;
private readonly UserManager<ApplicationUser> _userManager; private readonly UserManager<ApplicationUser> _userManager;
private readonly ISubscriptionService _subscriptionService; private readonly ISubscriptionService _subscriptionService;
private readonly IInventoryAiLookupService _aiLookupService;
public DashboardController( public DashboardController(
IUnitOfWork unitOfWork, IUnitOfWork unitOfWork,
@@ -33,7 +34,8 @@ public class DashboardController : Controller
ITenantContext tenantContext, ITenantContext tenantContext,
ICompanyConfigHealthService configHealth, ICompanyConfigHealthService configHealth,
UserManager<ApplicationUser> userManager, UserManager<ApplicationUser> userManager,
ISubscriptionService subscriptionService) ISubscriptionService subscriptionService,
IInventoryAiLookupService aiLookupService)
{ {
_unitOfWork = unitOfWork; _unitOfWork = unitOfWork;
_logger = logger; _logger = logger;
@@ -42,6 +44,7 @@ public class DashboardController : Controller
_configHealth = configHealth; _configHealth = configHealth;
_userManager = userManager; _userManager = userManager;
_subscriptionService = subscriptionService; _subscriptionService = subscriptionService;
_aiLookupService = aiLookupService;
} }
/// <summary> /// <summary>
@@ -496,7 +499,7 @@ public class DashboardController : Controller
return null; return null;
// These share the same scoped DbContext so must run sequentially // These share the same scoped DbContext so must run sequentially
var hasStatusHistory = await _unitOfWork.JobStatusHistory.AnyAsync(_ => true); var hasStatusHistory = await _unitOfWork.JobStatusHistory.AnyAsync(h => h.CompanyId == companyId);
// ignoreQueryFilters so soft-deleted lookups (UpdatedAt set on delete) are also visible // ignoreQueryFilters so soft-deleted lookups (UpdatedAt set on delete) are also visible
var hasCustomizedLookups = await _unitOfWork.JobStatusLookups.AnyAsync( var hasCustomizedLookups = await _unitOfWork.JobStatusLookups.AnyAsync(
j => j.CompanyId == companyId && j.UpdatedAt != null, j => j.CompanyId == companyId && j.UpdatedAt != null,
@@ -765,27 +768,147 @@ public class DashboardController : Controller
UpdatedAt = DateTime.UtcNow, UpdatedAt = DateTime.UtcNow,
}; };
// Enrich from the platform powder catalog so the new inventory record carries the full
// spec/doc set (cure schedule, SDS/TDS, sample image, color families) rather than just
// the color code/name carried on the quote. Match by the catalog SKU (stored as the
// coat's colorCode), preferring the same manufacturer; fall back to color name.
await EnrichInventoryFromCatalogAsync(inventoryItem, colorCode, colorName, manufacturer);
var linkedCount = await FinalizeReceivedPowderAsync(
coat, inventoryItem, lbsReceived, companyId, colorCode, colorName, primaryVendorId,
jobItem?.Job?.JobNumber);
return Json(new { success = true, itemName = inventoryItem.Name, sku = inventoryItem.SKU, linkedCount });
}
catch (Exception ex)
{
_logger.LogError(ex, "Error adding custom powder to inventory for coat {CoatId}", coatId);
return Json(new { success = false, message = "An error occurred while saving." });
}
}
/// <summary>
/// Finds the platform powder catalog row for an inventory/coat identity: by catalog SKU
/// (stored as the coat's color code), preferring the same manufacturer, then by color name.
/// Returns null when no match is found.
/// </summary>
private async Task<PowderCatalogItem?> FindCatalogByIdentityAsync(
string? colorCode, string? colorName, string? manufacturer)
{
var code = colorCode?.Trim();
if (!string.IsNullOrWhiteSpace(code))
{
var codeLower = code.ToLower();
var hits = (await _unitOfWork.PowderCatalog.FindAsync(p => p.Sku.ToLower() == codeLower)).ToList();
var mfr = manufacturer?.Trim().ToLower();
var match = (!string.IsNullOrWhiteSpace(mfr)
? hits.FirstOrDefault(p => p.VendorName.ToLower().Contains(mfr))
: null)
?? hits.FirstOrDefault();
if (match != null)
return match;
}
if (!string.IsNullOrWhiteSpace(colorName))
{
var nameLower = colorName.Trim().ToLower();
return (await _unitOfWork.PowderCatalog.FindAsync(p => p.ColorName.ToLower() == nameLower))
.FirstOrDefault();
}
return null;
}
/// <summary>
/// Copies catalog spec/document fields onto an inventory item — cure schedule, coverage,
/// specific gravity, transfer efficiency, SDS/TDS links, sample image, color families, product
/// page — and links <see cref="InventoryItem.PowderCatalogItemId"/>. Only fills gaps, so any
/// value already set (e.g. entered on the receive form) is preserved.
/// </summary>
private static void ApplyCatalogToInventory(InventoryItem item, PowderCatalogItem catalog)
{
item.PowderCatalogItemId = catalog.Id;
if (string.IsNullOrWhiteSpace(item.ManufacturerPartNumber)) item.ManufacturerPartNumber = catalog.Sku;
if (string.IsNullOrWhiteSpace(item.Manufacturer)) item.Manufacturer = catalog.VendorName;
if (string.IsNullOrWhiteSpace(item.ColorName)) item.ColorName = catalog.ColorName;
if (string.IsNullOrWhiteSpace(item.Finish)) item.Finish = catalog.Finish;
if (string.IsNullOrWhiteSpace(item.ColorFamilies)) item.ColorFamilies = catalog.ColorFamilies;
if (string.IsNullOrWhiteSpace(item.ImageUrl)) item.ImageUrl = catalog.ImageUrl;
if (string.IsNullOrWhiteSpace(item.SdsUrl)) item.SdsUrl = catalog.SdsUrl;
if (string.IsNullOrWhiteSpace(item.TdsUrl)) item.TdsUrl = catalog.TdsUrl;
if (string.IsNullOrWhiteSpace(item.SpecPageUrl)) item.SpecPageUrl = catalog.ProductUrl;
item.CureTemperatureF ??= catalog.CureTemperatureF;
item.CureTimeMinutes ??= catalog.CureTimeMinutes;
item.SpecificGravity ??= catalog.SpecificGravity;
item.CoverageSqFtPerLb ??= catalog.CoverageSqFtPerLb ?? 30m;
item.TransferEfficiency ??= catalog.TransferEfficiency ?? 65m;
if (!item.RequiresClearCoat && catalog.RequiresClearCoat == true)
item.RequiresClearCoat = true;
if (item.UnitCost <= 0 && catalog.UnitPrice > 0)
{
item.UnitCost = catalog.UnitPrice;
item.LastPurchasePrice = catalog.UnitPrice;
}
// Quoting reference price (current catalog list price) — separate from cost basis above.
if (catalog.UnitPrice > 0)
{
item.CatalogReferencePrice = catalog.UnitPrice;
item.CatalogPriceUpdatedAt = DateTime.UtcNow;
}
}
/// <summary>
/// Fills blank spec/document fields on a received custom-powder inventory item from the matching
/// platform powder catalog row, so the tenant gets a complete record instead of just the color
/// code/name carried on the quote. No-op when the powder isn't in the catalog.
/// </summary>
private async Task EnrichInventoryFromCatalogAsync(
InventoryItem item, string? colorCode, string? colorName, string? manufacturer)
{
var catalog = await FindCatalogByIdentityAsync(colorCode, colorName, manufacturer);
if (catalog == null)
return;
// First use — lazily fill specific gravity / cure from the TDS before copying onto the item.
await _aiLookupService.EnsureCatalogTdsSpecsAsync(catalog);
ApplyCatalogToInventory(item, catalog);
}
/// <summary>
/// Shared finalize for a received powder: saves the inventory item, writes the opening Purchase
/// transaction, marks the coat received and links it, then links any sibling coats ordering the
/// same color. Returns the number of additional coats linked. Used by both the manual modal
/// (<see cref="AddCustomPowderToInventory"/>) and the catalog auto-receive
/// (<see cref="ReceivePowderFromCatalog"/>).
/// </summary>
private async Task<int> FinalizeReceivedPowderAsync(
JobItemCoat coat, InventoryItem inventoryItem, decimal lbsReceived, int companyId,
string? colorCode, string? colorName, int? primaryVendorId, string? jobNumber)
{
await _unitOfWork.InventoryItems.AddAsync(inventoryItem); await _unitOfWork.InventoryItems.AddAsync(inventoryItem);
await _unitOfWork.CompleteAsync(); // flush to get inventoryItem.Id await _unitOfWork.CompleteAsync(); // flush to get inventoryItem.Id
// Opening stock transaction
var transaction = new InventoryTransaction var transaction = new InventoryTransaction
{ {
CompanyId = companyId, CompanyId = companyId,
InventoryItemId = inventoryItem.Id, InventoryItemId = inventoryItem.Id,
TransactionType = InventoryTransactionType.Purchase, TransactionType = InventoryTransactionType.Purchase,
Quantity = lbsReceived, Quantity = lbsReceived,
UnitCost = unitCost ?? 0, UnitCost = inventoryItem.UnitCost,
TotalCost = lbsReceived * (unitCost ?? 0), TotalCost = lbsReceived * inventoryItem.UnitCost,
TransactionDate = DateTime.UtcNow, TransactionDate = DateTime.UtcNow,
Notes = $"Initial stock — received from powder order for job {jobItem?.Job?.JobNumber}", Notes = $"Initial stock — received from powder order for job {jobNumber}",
BalanceAfter = lbsReceived, BalanceAfter = lbsReceived,
CreatedAt = DateTime.UtcNow, CreatedAt = DateTime.UtcNow,
UpdatedAt = DateTime.UtcNow, UpdatedAt = DateTime.UtcNow,
}; };
await _unitOfWork.InventoryTransactions.AddAsync(transaction); await _unitOfWork.InventoryTransactions.AddAsync(transaction);
// Mark coat as received and link to the new inventory item
var userId = User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value; var userId = User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value;
coat.PowderReceived = true; coat.PowderReceived = true;
coat.PowderReceivedAt = DateTime.UtcNow; coat.PowderReceivedAt = DateTime.UtcNow;
@@ -793,21 +916,18 @@ public class DashboardController : Controller
coat.PowderReceivedLbs = lbsReceived; coat.PowderReceivedLbs = lbsReceived;
coat.InventoryItemId = inventoryItem.Id; coat.InventoryItemId = inventoryItem.Id;
// Scan for sibling coats with the same custom powder and link them to the new item var candidateCoats = await _unitOfWork.JobItemCoats.GetCandidateCoatsForLinkingAsync(coat.Id, companyId);
var candidateCoats = await _unitOfWork.JobItemCoats.GetCandidateCoatsForLinkingAsync(coatId, companyId);
int linkedCount = 0; var linkedCount = 0;
foreach (var other in candidateCoats) foreach (var other in candidateCoats)
{ {
bool colorMatch = !string.IsNullOrWhiteSpace(colorCode) var colorMatch = !string.IsNullOrWhiteSpace(colorCode)
? string.Equals(other.ColorCode?.Trim(), colorCode.Trim(), StringComparison.OrdinalIgnoreCase) ? string.Equals(other.ColorCode?.Trim(), colorCode.Trim(), StringComparison.OrdinalIgnoreCase)
: !string.IsNullOrWhiteSpace(colorName) && : !string.IsNullOrWhiteSpace(colorName) &&
string.Equals(other.ColorName?.Trim(), colorName.Trim(), StringComparison.OrdinalIgnoreCase); string.Equals(other.ColorName?.Trim(), colorName.Trim(), StringComparison.OrdinalIgnoreCase);
if (!colorMatch) continue; if (!colorMatch) continue;
if (primaryVendorId.HasValue && other.VendorId.HasValue && other.VendorId != primaryVendorId) continue;
if (primaryVendorId.HasValue && other.VendorId.HasValue && other.VendorId != primaryVendorId)
continue;
other.InventoryItemId = inventoryItem.Id; other.InventoryItemId = inventoryItem.Id;
linkedCount++; linkedCount++;
@@ -818,12 +938,112 @@ public class DashboardController : Controller
linkedCount, inventoryItem.Id, inventoryItem.SKU); linkedCount, inventoryItem.Id, inventoryItem.SKU);
await _unitOfWork.CompleteAsync(); await _unitOfWork.CompleteAsync();
return linkedCount;
}
return Json(new { success = true, itemName = inventoryItem.Name, sku = inventoryItem.SKU, linkedCount }); /// <summary>
/// Generates a unique powder SKU for a company in the form <c>{CODE}-{YYMM}-{####}</c>, where
/// CODE is the (padded) inventory category code. Mirrors the inventory SKU pattern used when
/// adding catalog-sourced powders.
/// </summary>
private async Task<string> GeneratePowderSkuAsync(InventoryCategoryLookup category)
{
var code = category.CategoryCode.Length >= 4
? category.CategoryCode[..4].ToUpperInvariant()
: category.CategoryCode.ToUpperInvariant().PadRight(4, 'X');
var yearMonth = DateTime.Now.ToString("yyMM");
var prefix = $"{code}-{yearMonth}-";
var allItems = await _unitOfWork.InventoryItems.GetAllAsync(ignoreQueryFilters: true);
var maxSeq = allItems
.Where(i => i.SKU.StartsWith(prefix))
.Select(i => int.TryParse(i.SKU[prefix.Length..], out var n) ? n : 0)
.DefaultIfEmpty(0)
.Max();
return $"{prefix}{(maxSeq + 1):D4}";
}
/// <summary>
/// Receives an ordered custom powder straight into inventory WITHOUT the manual modal when the
/// powder is already in the master catalog — the new record is fully populated from the catalog
/// (specs, SDS/TDS, image, pricing). Returns <c>needsDetails = true</c> (without saving) when
/// the powder isn't in the catalog or no coating category is configured, signaling the caller to
/// fall back to the manual entry modal.
/// </summary>
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> ReceivePowderFromCatalog(int coatId, decimal lbsReceived)
{
try
{
if (lbsReceived <= 0)
return Json(new { success = false, message = "Quantity received must be greater than zero." });
var coat = await _unitOfWork.JobItemCoats.GetByIdAsync(coatId);
if (coat == null)
return Json(new { success = false, message = "Coat record not found." });
var jobItem = await _unitOfWork.JobItems.FirstOrDefaultAsync(
i => i.Coats.Any(c => c.Id == coatId), false, i => i.Job);
var companyId = jobItem?.Job?.CompanyId ?? _tenantContext.GetCurrentCompanyId() ?? 0;
// Only auto-receive when the powder resolves in the master catalog; otherwise the caller
// opens the manual modal.
var catalog = await FindCatalogByIdentityAsync(coat.ColorCode, coat.ColorName, null);
if (catalog == null)
return Json(new { success = false, needsDetails = true });
// First use — lazily fill specific gravity / cure from the TDS so the new record is complete.
await _aiLookupService.EnsureCatalogTdsSpecsAsync(catalog);
// Resolve the company's POWDER (coating) inventory category.
var categories = await _unitOfWork.InventoryCategoryLookups.FindAsync(c => c.CompanyId == companyId);
var coatingCategory = categories.FirstOrDefault(c => c.IsActive && c.IsCoating
&& c.CategoryCode.Equals("POWDER", StringComparison.OrdinalIgnoreCase))
?? categories.Where(c => c.IsActive && c.IsCoating)
.OrderBy(c => c.DisplayOrder).FirstOrDefault();
if (coatingCategory == null)
return Json(new { success = false, needsDetails = true });
var sku = await GeneratePowderSkuAsync(coatingCategory);
var inventoryItem = new InventoryItem
{
CompanyId = companyId,
SKU = sku,
Name = catalog.ColorName,
ColorName = catalog.ColorName,
ColorCode = coat.ColorCode,
InventoryCategoryId = coatingCategory.Id,
Category = coatingCategory.DisplayName,
QuantityOnHand = lbsReceived,
UnitOfMeasure = "lbs",
UnitCost = catalog.UnitPrice,
LastPurchasePrice = catalog.UnitPrice,
LastPurchaseDate = DateTime.UtcNow,
IsActive = true,
CreatedAt = DateTime.UtcNow,
UpdatedAt = DateTime.UtcNow,
};
ApplyCatalogToInventory(inventoryItem, catalog);
var linkedCount = await FinalizeReceivedPowderAsync(
coat, inventoryItem, lbsReceived, companyId, coat.ColorCode, coat.ColorName, null,
jobItem?.Job?.JobNumber);
return Json(new
{
success = true,
fromCatalog = true,
itemName = inventoryItem.Name,
sku = inventoryItem.SKU,
linkedCount
});
} }
catch (Exception ex) catch (Exception ex)
{ {
_logger.LogError(ex, "Error adding custom powder to inventory for coat {CoatId}", coatId); _logger.LogError(ex, "Error auto-receiving powder from catalog for coat {CoatId}", coatId);
return Json(new { success = false, message = "An error occurred while saving." }); return Json(new { success = false, message = "An error occurred while saving." });
} }
} }
@@ -9,6 +9,7 @@ using PowderCoating.Core.Entities;
using PowderCoating.Core.Enums; using PowderCoating.Core.Enums;
using PowderCoating.Core.Interfaces; using PowderCoating.Core.Interfaces;
using PowderCoating.Shared.Constants; using PowderCoating.Shared.Constants;
using PowderCoating.Web.Helpers;
using QuestPDF.Fluent; using QuestPDF.Fluent;
using QuestPDF.Helpers; using QuestPDF.Helpers;
using QuestPDF.Infrastructure; using QuestPDF.Infrastructure;
@@ -63,7 +64,8 @@ public class DepositsController : Controller
string paymentMethod, string paymentMethod,
DateTime receivedDate, DateTime receivedDate,
string? reference, string? reference,
string? notes) string? notes,
int? depositAccountId = null)
{ {
try try
{ {
@@ -80,7 +82,32 @@ public class DepositsController : Controller
if (currentUser == null) return Unauthorized(); if (currentUser == null) return Unauthorized();
var receiptNumber = await GenerateReceiptNumberAsync(currentUser.CompanyId); var receiptNumber = await GenerateReceiptNumberAsync(currentUser.CompanyId);
var checkingAcctId = await GetCheckingAccountIdAsync(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 deposit = new Deposit var deposit = new Deposit
{ {
@@ -93,7 +120,7 @@ public class DepositsController : Controller
ReceivedDate = receivedDate, ReceivedDate = receivedDate,
Reference = reference, Reference = reference,
Notes = notes, Notes = notes,
DepositAccountId = checkingAcctId, DepositAccountId = depositAcctId,
RecordedById = currentUser.Id, RecordedById = currentUser.Id,
CompanyId = currentUser.CompanyId, CompanyId = currentUser.CompanyId,
CreatedAt = DateTime.UtcNow, CreatedAt = DateTime.UtcNow,
@@ -104,8 +131,7 @@ public class DepositsController : Controller
await _unitOfWork.CompleteAsync(); await _unitOfWork.CompleteAsync();
// GL: DR Checking (cash received) / CR Customer Deposits 2300 (liability until applied to invoice). // GL: DR Checking (cash received) / CR Customer Deposits 2300 (liability until applied to invoice).
var custDepositsAcctId = await GetCustomerDepositsAccountIdAsync(currentUser.CompanyId); await _accountBalanceService.DebitAsync(depositAcctId, deposit.Amount);
await _accountBalanceService.DebitAsync(checkingAcctId, deposit.Amount);
await _accountBalanceService.CreditAsync(custDepositsAcctId, deposit.Amount); await _accountBalanceService.CreditAsync(custDepositsAcctId, deposit.Amount);
return Json(new return Json(new
@@ -105,8 +105,9 @@ public class ExpensesController : Controller
ViewBag.To = to?.ToString("yyyy-MM-dd"); ViewBag.To = to?.ToString("yyyy-MM-dd");
ViewBag.TotalAmount = dtos.Sum(e => e.Amount); ViewBag.TotalAmount = dtos.Sum(e => e.Amount);
var legacyUser = await _userManager.GetUserAsync(User);
var expenseAccounts = (await _unitOfWork.Accounts.FindAsync( var expenseAccounts = (await _unitOfWork.Accounts.FindAsync(
a => a.IsActive && a => a.CompanyId == legacyUser!.CompanyId && a.IsActive &&
(a.AccountType == AccountType.Expense || a.AccountType == AccountType.CostOfGoods))) (a.AccountType == AccountType.Expense || a.AccountType == AccountType.CostOfGoods)))
.OrderBy(a => a.AccountNumber) .OrderBy(a => a.AccountNumber)
.ToList(); .ToList();
@@ -479,7 +480,8 @@ public class ExpensesController : Controller
if (!request.AvailableAccounts.Any()) if (!request.AvailableAccounts.Any())
{ {
var allAccounts = await _unitOfWork.Accounts.FindAsync(a => a.IsActive); var currentUser = await _userManager.GetUserAsync(User);
var allAccounts = await _unitOfWork.Accounts.FindAsync(a => a.CompanyId == currentUser!.CompanyId && a.IsActive);
request.AvailableAccounts = allAccounts request.AvailableAccounts = allAccounts
.Where(a => a.AccountType == AccountType.Expense || .Where(a => a.AccountType == AccountType.Expense ||
a.AccountType == AccountType.CostOfGoods) a.AccountType == AccountType.CostOfGoods)
@@ -44,8 +44,9 @@ public class FixedAssetsController : Controller
[HttpGet] [HttpGet]
public async Task<IActionResult> Index() public async Task<IActionResult> Index()
{ {
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var assets = await _unitOfWork.FixedAssets.FindAsync( var assets = await _unitOfWork.FixedAssets.FindAsync(
fa => true, false, fa => fa.CompanyId == companyId, false,
fa => fa.AssetAccount, fa => fa.AssetAccount,
fa => fa.DepreciationExpenseAccount, fa => fa.DepreciationExpenseAccount,
fa => fa.AccumDepreciationAccount); fa => fa.AccumDepreciationAccount);
@@ -192,7 +193,7 @@ public class FixedAssetsController : Controller
var currentUser = await _userManager.GetUserAsync(User); var currentUser = await _userManager.GetUserAsync(User);
var assets = await _unitOfWork.FixedAssets.FindAsync( var assets = await _unitOfWork.FixedAssets.FindAsync(
fa => !fa.IsDisposed, false, fa => fa.CompanyId == companyId && !fa.IsDisposed, false,
fa => fa.DepreciationEntries); fa => fa.DepreciationEntries);
int posted = 0, skipped = 0; int posted = 0, skipped = 0;
@@ -313,7 +314,8 @@ public class FixedAssetsController : Controller
private async Task PopulateAccountsAsync() private async Task PopulateAccountsAsync()
{ {
var accounts = await _unitOfWork.Accounts.FindAsync(a => a.IsActive); var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var accounts = await _unitOfWork.Accounts.FindAsync(a => a.CompanyId == companyId && a.IsActive);
var list = accounts.OrderBy(a => a.AccountNumber).ThenBy(a => a.Name).ToList(); var list = accounts.OrderBy(a => a.AccountNumber).ThenBy(a => a.Name).ToList();
ViewBag.AssetAccounts = list ViewBag.AssetAccounts = list
@@ -62,8 +62,9 @@ public class GiftCertificatesController : Controller
/// </summary> /// </summary>
public async Task<IActionResult> Index(string? searchTerm, string? statusFilter) public async Task<IActionResult> Index(string? searchTerm, string? statusFilter)
{ {
var companyId = (await _userManager.GetUserAsync(User))?.CompanyId ?? 0;
var certs = await _unitOfWork.GiftCertificates.FindAsync( var certs = await _unitOfWork.GiftCertificates.FindAsync(
gc => true, false, gc => gc.CompanyId == companyId, false,
gc => gc.RecipientCustomer, gc => gc.RecipientCustomer,
gc => gc.PurchasingCustomer); gc => gc.PurchasingCustomer);
@@ -254,14 +255,14 @@ public class GiftCertificatesController : Controller
if (dto.IssuedReason == GiftCertificateIssuedReason.Sold) if (dto.IssuedReason == GiftCertificateIssuedReason.Sold)
{ {
var checkingAcctId = await _unitOfWork.Accounts.FirstOrDefaultAsync( var checkingAcctId = await _unitOfWork.Accounts.FirstOrDefaultAsync(
a => a.IsActive && (a.AccountSubType == AccountSubTypeEnum.Checking a => a.CompanyId == companyId && a.IsActive && (a.AccountSubType == AccountSubTypeEnum.Checking
|| a.AccountSubType == AccountSubTypeEnum.Cash)); || a.AccountSubType == AccountSubTypeEnum.Cash));
await _accountBalanceService.DebitAsync(checkingAcctId?.Id, cert.OriginalAmount); await _accountBalanceService.DebitAsync(checkingAcctId?.Id, cert.OriginalAmount);
} }
else else
{ {
var discountAcctId = await _unitOfWork.Accounts.FirstOrDefaultAsync( var discountAcctId = await _unitOfWork.Accounts.FirstOrDefaultAsync(
a => a.IsActive && a.AccountNumber == "4950"); a => a.CompanyId == companyId && a.IsActive && a.AccountNumber == "4950");
await _accountBalanceService.DebitAsync(discountAcctId?.Id, cert.OriginalAmount); await _accountBalanceService.DebitAsync(discountAcctId?.Id, cert.OriginalAmount);
} }
@@ -310,7 +311,7 @@ public class GiftCertificatesController : Controller
var companyId = currentUser?.CompanyId ?? 0; var companyId = currentUser?.CompanyId ?? 0;
var gcLiabilityAcctId = await GetGcLiabilityAccountIdAsync(companyId); var gcLiabilityAcctId = await GetGcLiabilityAccountIdAsync(companyId);
var otherIncomeAcctId = await _unitOfWork.Accounts.FirstOrDefaultAsync( var otherIncomeAcctId = await _unitOfWork.Accounts.FirstOrDefaultAsync(
a => a.IsActive && a.AccountSubType == AccountSubTypeEnum.OtherIncome); a => a.CompanyId == companyId && a.IsActive && a.AccountSubType == AccountSubTypeEnum.OtherIncome);
await _accountBalanceService.DebitAsync(gcLiabilityAcctId, remaining); await _accountBalanceService.DebitAsync(gcLiabilityAcctId, remaining);
await _accountBalanceService.CreditAsync(otherIncomeAcctId?.Id, remaining); await _accountBalanceService.CreditAsync(otherIncomeAcctId?.Id, remaining);
} }
@@ -420,7 +421,8 @@ public class GiftCertificatesController : Controller
/// </summary> /// </summary>
private async Task PopulateCustomersAsync() private async Task PopulateCustomersAsync()
{ {
var customers = await _unitOfWork.Customers.FindAsync(c => c.IsActive); var companyId = (await _userManager.GetUserAsync(User))?.CompanyId ?? 0;
var customers = await _unitOfWork.Customers.FindAsync(c => c.CompanyId == companyId && c.IsActive);
var list = customers var list = customers
.OrderBy(c => c.CompanyName ?? c.ContactFirstName) .OrderBy(c => c.CompanyName ?? c.ContactFirstName)
.Select(c => new SelectListItem .Select(c => new SelectListItem
@@ -437,7 +439,7 @@ public class GiftCertificatesController : Controller
private async Task<int?> GetGcLiabilityAccountIdAsync(int companyId) private async Task<int?> GetGcLiabilityAccountIdAsync(int companyId)
{ {
var acct = await _unitOfWork.Accounts.FirstOrDefaultAsync( var acct = await _unitOfWork.Accounts.FirstOrDefaultAsync(
a => a.IsActive && a.AccountNumber == "2500"); a => a.CompanyId == companyId && a.IsActive && a.AccountNumber == "2500");
return acct?.Id; return acct?.Id;
} }
@@ -477,14 +479,14 @@ public class GiftCertificatesController : Controller
if (dto.IssuedReason == GiftCertificateIssuedReason.Sold) if (dto.IssuedReason == GiftCertificateIssuedReason.Sold)
{ {
var acct = await _unitOfWork.Accounts.FirstOrDefaultAsync( var acct = await _unitOfWork.Accounts.FirstOrDefaultAsync(
a => a.IsActive && (a.AccountSubType == AccountSubTypeEnum.Checking a => a.CompanyId == companyId && a.IsActive && (a.AccountSubType == AccountSubTypeEnum.Checking
|| a.AccountSubType == AccountSubTypeEnum.Cash)); || a.AccountSubType == AccountSubTypeEnum.Cash));
checkingAcctId = acct?.Id; checkingAcctId = acct?.Id;
} }
else else
{ {
var acct = await _unitOfWork.Accounts.FirstOrDefaultAsync( var acct = await _unitOfWork.Accounts.FirstOrDefaultAsync(
a => a.IsActive && a.AccountNumber == "4950"); a => a.CompanyId == companyId && a.IsActive && a.AccountNumber == "4950");
discountAcctId = acct?.Id; discountAcctId = acct?.Id;
} }
@@ -126,11 +126,12 @@ public class InAppNotificationsController : Controller
public async Task<IActionResult> MarkAllRead() public async Task<IActionResult> MarkAllRead()
{ {
var now = DateTime.UtcNow; var now = DateTime.UtcNow;
var companyId = _tenant.GetCurrentCompanyId() ?? 0;
var unread = _tenant.IsPlatformAdmin() var unread = _tenant.IsPlatformAdmin()
? (await _unitOfWork.InAppNotifications.FindAsync( ? (await _unitOfWork.InAppNotifications.FindAsync(
n => !n.IsDeleted && n.CompanyId == 0 && !n.IsRead, ignoreQueryFilters: true)).ToList() n => !n.IsDeleted && n.CompanyId == 0 && !n.IsRead, ignoreQueryFilters: true)).ToList()
: (await _unitOfWork.InAppNotifications.FindAsync(n => !n.IsRead)).ToList(); : (await _unitOfWork.InAppNotifications.FindAsync(n => n.CompanyId == companyId && !n.IsRead)).ToList();
foreach (var n in unread) foreach (var n in unread)
{ {
@@ -193,8 +193,9 @@ public class InventoryController : Controller
return RedirectToAction(nameof(Index)); return RedirectToAction(nameof(Index));
var loc = location.Trim(); var loc = location.Trim();
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var items = await _unitOfWork.InventoryItems.FindAsync( var items = await _unitOfWork.InventoryItems.FindAsync(
i => i.Location != null && i.Location.ToLower() == loc.ToLower()); i => i.CompanyId == companyId && i.Location != null && i.Location.ToLower() == loc.ToLower());
var dtos = _mapper.Map<List<InventoryListDto>>(items.OrderBy(i => i.Name).ToList()); var dtos = _mapper.Map<List<InventoryListDto>>(items.OrderBy(i => i.Name).ToList());
ViewBag.Location = loc; ViewBag.Location = loc;
@@ -240,6 +241,17 @@ public class InventoryController : Controller
var useMetric = await _tenantContext.UseMetricSystemAsync(); var useMetric = await _tenantContext.UseMetricSystemAsync();
ViewBag.CoverageUnit = _measurementService.GetCoverageUnitLabel(useMetric); ViewBag.CoverageUnit = _measurementService.GetCoverageUnitLabel(useMetric);
// Manufacturer-level catalog status: prefer the linked catalog row, fall back to an
// identity match for items added before they were linked. Drives the "discontinued by
// manufacturer — cannot reorder" warning. This is distinct from the shop's own
// IsActive/DiscontinuedDate (whether the shop still stocks it).
var catalogItem = item.PowderCatalogItemId.HasValue
? await _unitOfWork.PowderCatalog.GetByIdAsync(item.PowderCatalogItemId.Value)
: await FindCatalogMatchAsync(item.Manufacturer, item.ManufacturerPartNumber);
ViewBag.CatalogDiscontinued = catalogItem?.IsDiscontinued ?? false;
ViewBag.CatalogVendorName = catalogItem?.VendorName;
ViewBag.CatalogProductUrl = catalogItem?.ProductUrl;
return View(itemDto); return View(itemDto);
} }
catch (Exception ex) catch (Exception ex)
@@ -264,10 +276,18 @@ public class InventoryController : Controller
ViewBag.UseMetric = useMetric; ViewBag.UseMetric = useMetric;
ViewBag.CoverageUnit = _measurementService.GetCoverageUnitLabel(useMetric); ViewBag.CoverageUnit = _measurementService.GetCoverageUnitLabel(useMetric);
// Pre-fill the GL account dropdowns from the company's configured defaults so new items
// inherit them (the user can still change or clear them on the form).
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var prefs = await _unitOfWork.CompanyPreferences
.FirstOrDefaultAsync(p => p.CompanyId == companyId && !p.IsDeleted);
return View(new CreateInventoryItemDto return View(new CreateInventoryItemDto
{ {
CoverageSqFtPerLb = 30, CoverageSqFtPerLb = 30,
TransferEfficiency = 65 TransferEfficiency = 65,
InventoryAccountId = prefs?.DefaultInventoryAccountId,
CogsAccountId = prefs?.DefaultCogsAccountId
}); });
} }
@@ -287,6 +307,27 @@ public class InventoryController : Controller
return View(dto); 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 try
{ {
var item = _mapper.Map<InventoryItem>(dto); var item = _mapper.Map<InventoryItem>(dto);
@@ -295,11 +336,21 @@ public class InventoryController : Controller
item.Name = ToTitleCase(item.Name); item.Name = ToTitleCase(item.Name);
// Populate legacy Category field from lookup table // Populate legacy Category field from lookup table
if (item.InventoryCategoryId.HasValue)
{
var category = await _unitOfWork.InventoryCategoryLookups.GetByIdAsync(item.InventoryCategoryId.Value);
if (category != null) if (category != null)
item.Category = category.DisplayName; 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
// can use the current catalog price.
var catalogMatch = await FindCatalogMatchAsync(item.Manufacturer, item.ManufacturerPartNumber);
if (catalogMatch != null)
{
item.PowderCatalogItemId = catalogMatch.Id;
if (catalogMatch.UnitPrice > 0)
{
item.CatalogReferencePrice = catalogMatch.UnitPrice;
item.CatalogPriceUpdatedAt = DateTime.UtcNow;
}
} }
await _unitOfWork.InventoryItems.AddAsync(item); await _unitOfWork.InventoryItems.AddAsync(item);
@@ -763,6 +814,24 @@ public class InventoryController : Controller
/// Returns (wasInCatalog, addedToCatalog) so callers can surface UI badges. /// Returns (wasInCatalog, addedToCatalog) so callers can surface UI badges.
/// Mutates <paramref name="result"/> in place. /// Mutates <paramref name="result"/> in place.
/// </summary> /// </summary>
/// <summary>
/// Finds the platform powder catalog row matching an inventory item's identity
/// (Manufacturer + ManufacturerPartNumber), or null. Used to set
/// <see cref="InventoryItem.PowderCatalogItemId"/> and to surface manufacturer-level status
/// (e.g. discontinued / cannot reorder) on the detail screen.
/// </summary>
private async Task<PowderCatalogItem?> FindCatalogMatchAsync(string? manufacturer, string? sku)
{
if (string.IsNullOrWhiteSpace(manufacturer) || string.IsNullOrWhiteSpace(sku))
return null;
var skuLower = sku.Trim().ToLower();
var mfrLower = manufacturer.Trim().ToLower();
var hits = await _unitOfWork.PowderCatalog.FindAsync(p =>
p.Sku.ToLower() == skuLower && p.VendorName.ToLower().Contains(mfrLower));
return hits.FirstOrDefault();
}
private async Task<(bool wasInCatalog, bool addedToCatalog)> EnrichFromCatalogAsync( private async Task<(bool wasInCatalog, bool addedToCatalog)> EnrichFromCatalogAsync(
InventoryAiLookupResult result, bool autoContribute) InventoryAiLookupResult result, bool autoContribute)
{ {
@@ -999,45 +1068,12 @@ public class InventoryController : Controller
// TDS cure fallback — same logic as AiLookup button // TDS cure fallback — same logic as AiLookup button
await ApplyTdsCureFallbackAsync(aiResult, colorName); await ApplyTdsCureFallbackAsync(aiResult, colorName);
// Check if this product already exists in the tenant's inventory. var duplicate = await FindInventoryDuplicateAsync(
// Match by ManufacturerPartNumber first (most precise); fall back to color name + manufacturer. null,
// Returns the first active match so the UI can prompt to add stock inline. manufacturer,
int? existingInventoryId = null; sku,
string? existingInventoryName = null; colorName,
decimal? existingQuantityOnHand = null; isCoating: true);
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 return Json(new
{ {
@@ -1062,16 +1098,61 @@ public class InventoryController : Controller
vendorName = manufacturer, vendorName = manufacturer,
wasInCatalog = wasInCatalog, wasInCatalog = wasInCatalog,
addedToCatalog = addedToCatalog, addedToCatalog = addedToCatalog,
existingInventoryId = existingInventoryId, existingInventoryId = duplicate?.Item.Id,
existingInventoryName = existingInventoryName, existingInventoryName = duplicate?.Item.Name,
existingQuantityOnHand = existingQuantityOnHand, existingQuantityOnHand = duplicate?.Item.QuantityOnHand,
existingUnitOfMeasure = existingUnitOfMeasure, existingUnitOfMeasure = duplicate?.Item.UnitOfMeasure,
duplicateMatchType = duplicate?.MatchType.ToString(),
reasoning = aiResult.Reasoning, reasoning = aiResult.Reasoning,
}); });
} }
/// <summary> /// <summary>
/// Adds stock to an existing inventory item from the label scanner inline prompt. /// 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.
/// Creates a Purchase transaction and updates QuantityOnHand without navigating away. /// Creates a Purchase transaction and updates QuantityOnHand without navigating away.
/// </summary> /// </summary>
[HttpPost] [HttpPost]
@@ -1220,6 +1301,10 @@ public class InventoryController : Controller
if (catalogItem == null) if (catalogItem == null)
return Json(new { success = false, error = "Catalog item not found." }); return Json(new { success = false, error = "Catalog item not found." });
// First use of this powder — lazily fill specific gravity / cure from its TDS so the new
// inventory record (and the catalog) carry complete specs. No-op once already enriched.
await _aiLookupService.EnsureCatalogTdsSpecsAsync(catalogItem);
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0; var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
// Find the default coating category to assign. // Find the default coating category to assign.
@@ -1257,6 +1342,7 @@ public class InventoryController : Controller
ColorName = catalogItem.ColorName, ColorName = catalogItem.ColorName,
Manufacturer = catalogItem.VendorName, Manufacturer = catalogItem.VendorName,
ManufacturerPartNumber= catalogItem.Sku, ManufacturerPartNumber= catalogItem.Sku,
PowderCatalogItemId = catalogItem.Id,
Finish = catalogItem.Finish, Finish = catalogItem.Finish,
ColorFamilies = catalogItem.ColorFamilies, ColorFamilies = catalogItem.ColorFamilies,
RequiresClearCoat = catalogItem.RequiresClearCoat ?? false, RequiresClearCoat = catalogItem.RequiresClearCoat ?? false,
@@ -1272,6 +1358,8 @@ public class InventoryController : Controller
UnitCost = catalogItem.UnitPrice, UnitCost = catalogItem.UnitPrice,
AverageCost = catalogItem.UnitPrice, AverageCost = catalogItem.UnitPrice,
LastPurchasePrice = catalogItem.UnitPrice, LastPurchasePrice = catalogItem.UnitPrice,
CatalogReferencePrice = catalogItem.UnitPrice > 0 ? catalogItem.UnitPrice : (decimal?)null,
CatalogPriceUpdatedAt = catalogItem.UnitPrice > 0 ? DateTime.UtcNow : (DateTime?)null,
QuantityOnHand = 0, QuantityOnHand = 0,
UnitOfMeasure = "lbs", UnitOfMeasure = "lbs",
InventoryCategoryId = coatingCategory.Id, InventoryCategoryId = coatingCategory.Id,
@@ -1297,7 +1385,7 @@ public class InventoryController : Controller
efficiency = item.TransferEfficiency ?? 65m, efficiency = item.TransferEfficiency ?? 65m,
unitOfMeasure= item.UnitOfMeasure, unitOfMeasure= item.UnitOfMeasure,
categoryName = coatingCategory.DisplayName, categoryName = coatingCategory.DisplayName,
costPerLb = item.UnitCost, costPerLb = item.CatalogReferencePrice ?? item.UnitCost,
colorName = item.ColorName ?? item.Name, colorName = item.ColorName ?? item.Name,
colorCode = "", colorCode = "",
isIncoming = true isIncoming = true
@@ -1310,6 +1398,48 @@ 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) private static decimal GetEffectiveTransferEfficiency(decimal? transferEfficiency)
{ {
return transferEfficiency ?? DefaultTransferEfficiency; return transferEfficiency ?? DefaultTransferEfficiency;
@@ -1402,8 +1532,9 @@ public class InventoryController : Controller
{ {
try try
{ {
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var allCoatings = (await _unitOfWork.InventoryItems.FindAsync( var allCoatings = (await _unitOfWork.InventoryItems.FindAsync(
i => i.InventoryCategory != null && i.InventoryCategory.IsCoating, i => i.CompanyId == companyId && i.InventoryCategory != null && i.InventoryCategory.IsCoating,
false, false,
i => i.InventoryCategory)) i => i.InventoryCategory))
.OrderBy(i => i.Manufacturer).ThenBy(i => i.ColorName).ThenBy(i => i.Name) .OrderBy(i => i.Manufacturer).ThenBy(i => i.ColorName).ThenBy(i => i.Name)
@@ -1480,7 +1611,7 @@ public class InventoryController : Controller
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0; var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
ViewBag.AiInventoryAssistEnabled = await _subscriptionService.IsAiInventoryAssistEnabledAsync(companyId); ViewBag.AiInventoryAssistEnabled = await _subscriptionService.IsAiInventoryAssistEnabledAsync(companyId);
var vendors = (await _unitOfWork.Vendors.FindAsync(v => v.IsActive, false, v => v.Categories)) var vendors = (await _unitOfWork.Vendors.FindAsync(v => v.CompanyId == companyId && v.IsActive, false, v => v.Categories))
.OrderBy(v => v.CompanyName).ToList(); .OrderBy(v => v.CompanyName).ToList();
ViewBag.Vendors = new SelectList(vendors, "Id", "CompanyName"); ViewBag.Vendors = new SelectList(vendors, "Id", "CompanyName");
@@ -1519,12 +1650,17 @@ public class InventoryController : Controller
new SelectListItem { Value = "rolls", Text = "Rolls" } new SelectListItem { Value = "rolls", Text = "Rolls" }
}; };
var accounts = await _unitOfWork.Accounts.FindAsync(a => a.IsActive); var accounts = await _unitOfWork.Accounts.FindAsync(a => a.CompanyId == companyId && 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 ViewBag.InventoryAccounts = accounts
.Where(a => a.AccountType == AccountType.Asset .Where(a => a.AccountType == AccountType.Asset)
&& a.AccountSubType == AccountSubType.Inventory) .OrderByDescending(a => a.AccountSubType == AccountSubType.Inventory)
.OrderBy(a => a.AccountNumber) .ThenBy(a => a.AccountNumber)
.Select(a => new SelectListItem($"{a.AccountNumber} {a.Name}", a.Id.ToString())) .Select(a => new SelectListItem($"{a.AccountNumber} {a.Name}", a.Id.ToString()))
.ToList(); .ToList();
@@ -1533,6 +1669,13 @@ public class InventoryController : Controller
.OrderBy(a => a.AccountNumber) .OrderBy(a => a.AccountNumber)
.Select(a => new SelectListItem($"{a.AccountNumber} {a.Name}", a.Id.ToString())) .Select(a => new SelectListItem($"{a.AccountNumber} {a.Name}", a.Id.ToString()))
.ToList(); .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> /// <summary>
@@ -1710,13 +1853,16 @@ public class InventoryController : Controller
item.UpdatedAt = DateTime.UtcNow; item.UpdatedAt = DateTime.UtcNow;
await _unitOfWork.InventoryItems.UpdateAsync(item); 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 var txn = new InventoryTransaction
{ {
InventoryItemId = item.Id, InventoryItemId = item.Id,
TransactionType = transactionType, TransactionType = transactionType,
Quantity = -quantityUsed, Quantity = -quantityUsed,
UnitCost = item.UnitCost, UnitCost = effectiveUnitCost,
TotalCost = quantityUsed * item.UnitCost, TotalCost = quantityUsed * effectiveUnitCost,
TransactionDate = DateTime.UtcNow, TransactionDate = DateTime.UtcNow,
BalanceAfter = item.QuantityOnHand, BalanceAfter = item.QuantityOnHand,
JobId = jobId, JobId = jobId,
@@ -1730,7 +1876,7 @@ public class InventoryController : Controller
if (item.CogsAccountId.HasValue && item.InventoryAccountId.HasValue) if (item.CogsAccountId.HasValue && item.InventoryAccountId.HasValue)
{ {
var cost = quantityUsed * (item.AverageCost > 0 ? item.AverageCost : item.UnitCost); var cost = txn.TotalCost;
await _accountBalanceService.DebitAsync(item.CogsAccountId, cost); await _accountBalanceService.DebitAsync(item.CogsAccountId, cost);
await _accountBalanceService.CreditAsync(item.InventoryAccountId, cost); await _accountBalanceService.CreditAsync(item.InventoryAccountId, cost);
} }
@@ -2081,7 +2227,7 @@ public class InventoryController : Controller
return BadRequest("Only usage transactions can be edited here."); return BadRequest("Only usage transactions can be edited here.");
var allJobs = await _unitOfWork.Jobs.FindAsync( var allJobs = await _unitOfWork.Jobs.FindAsync(
j => !j.JobStatus.IsTerminalStatus, j => j.CompanyId == txn.CompanyId && !j.JobStatus.IsTerminalStatus,
false, false,
j => j.Customer, j => j.Customer,
j => j.JobStatus); j => j.JobStatus);
@@ -7,6 +7,7 @@ using PowderCoating.Application.DTOs.Common;
using PowderCoating.Application.DTOs.Invoice; using PowderCoating.Application.DTOs.Invoice;
using PowderCoating.Application.DTOs.Quote; using PowderCoating.Application.DTOs.Quote;
using PowderCoating.Application.Interfaces; using PowderCoating.Application.Interfaces;
using PowderCoating.Core.Accounting;
using PowderCoating.Core.Entities; using PowderCoating.Core.Entities;
using Microsoft.AspNetCore.Mvc.Rendering; using Microsoft.AspNetCore.Mvc.Rendering;
using PowderCoating.Core.Enums; using PowderCoating.Core.Enums;
@@ -240,11 +241,12 @@ public class InvoicesController : Controller
ViewBag.SortDirection = gridRequest.SortDirection; ViewBag.SortDirection = gridRequest.SortDirection;
// Pill badge counts — always global (not scoped to current filter/page) // Pill badge counts — always global (not scoped to current filter/page)
ViewBag.UnpaidCount = await _unitOfWork.Invoices.CountAsync(i => var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
i.Status == InvoiceStatus.Draft || i.Status == InvoiceStatus.Sent || i.Status == InvoiceStatus.Overdue); ViewBag.UnpaidCount = await _unitOfWork.Invoices.CountAsync(i => i.CompanyId == companyId &&
ViewBag.PartialCount = await _unitOfWork.Invoices.CountAsync(i => i.Status == InvoiceStatus.PartiallyPaid); (i.Status == InvoiceStatus.Draft || i.Status == InvoiceStatus.Sent || i.Status == InvoiceStatus.Overdue));
ViewBag.PaidCount = await _unitOfWork.Invoices.CountAsync(i => i.Status == InvoiceStatus.Paid); ViewBag.PartialCount = await _unitOfWork.Invoices.CountAsync(i => i.CompanyId == companyId && i.Status == InvoiceStatus.PartiallyPaid);
ViewBag.AllCount = await _unitOfWork.Invoices.CountAsync(); ViewBag.PaidCount = await _unitOfWork.Invoices.CountAsync(i => i.CompanyId == companyId && i.Status == InvoiceStatus.Paid);
ViewBag.AllCount = await _unitOfWork.Invoices.CountAsync(i => i.CompanyId == companyId);
return View(pagedResult); return View(pagedResult);
} }
@@ -304,8 +306,9 @@ public class InvoicesController : Controller
&& company?.StripeConnectStatus == StripeConnectStatus.Active; && company?.StripeConnectStatus == StripeConnectStatus.Active;
// Expense accounts for the write-off bad-debt modal // Expense accounts for the write-off bad-debt modal
var expenseCompanyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var expenseAccounts = await _unitOfWork.Accounts.FindAsync( var expenseAccounts = await _unitOfWork.Accounts.FindAsync(
a => a.IsActive && a.AccountType == AccountType.Expense); a => a.CompanyId == expenseCompanyId && a.IsActive && a.AccountType == AccountType.Expense);
ViewBag.ExpenseAccounts = expenseAccounts ViewBag.ExpenseAccounts = expenseAccounts
.OrderBy(a => a.AccountNumber).ThenBy(a => a.Name) .OrderBy(a => a.AccountNumber).ThenBy(a => a.Name)
.Select(a => new SelectListItem($"{a.AccountNumber} {a.Name}", a.Id.ToString())) .Select(a => new SelectListItem($"{a.AccountNumber} {a.Name}", a.Id.ToString()))
@@ -410,8 +413,15 @@ public class InvoicesController : Controller
.ToDictionary(ci => ci.Id) .ToDictionary(ci => ci.Id)
: new Dictionary<int, CatalogItem>(); : new Dictionary<int, CatalogItem>();
// Fall back to the default revenue account (4000) if a catalog item has no specific account // Fall back to the company's configured default revenue account when a catalog item
var defaultRevenueAccount = await _unitOfWork.Accounts // has no specific account; if none is configured (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
.FirstOrDefaultAsync(a => a.AccountNumber == "4000" && a.IsActive); .FirstOrDefaultAsync(a => a.AccountNumber == "4000" && a.IsActive);
// Deserialize the job's pricing snapshot up front — it is authoritative for discount, // Deserialize the job's pricing snapshot up front — it is authoritative for discount,
@@ -2404,7 +2414,7 @@ public class InvoicesController : Controller
return Json(new { taxPercent = 0m, taxRateName = (string?)null }); return Json(new { taxPercent = 0m, taxRateName = (string?)null });
var defaultRate = await _unitOfWork.TaxRates var defaultRate = await _unitOfWork.TaxRates
.FirstOrDefaultAsync(r => r.IsDefault && r.IsActive && !r.IsDeleted); .FirstOrDefaultAsync(r => r.CompanyId == customer.CompanyId && r.IsDefault && r.IsActive && !r.IsDeleted);
return Json(new return Json(new
{ {
@@ -2441,7 +2451,7 @@ public class InvoicesController : Controller
// Merchandise items for the invoice merch picker (all active IsMerchandise items) // Merchandise items for the invoice merch picker (all active IsMerchandise items)
var allMerchItems = await _unitOfWork.CatalogItems.FindAsync( var allMerchItems = await _unitOfWork.CatalogItems.FindAsync(
i => i.IsMerchandise && i.IsActive, false, i => i.Category); i => i.CompanyId == companyId && i.IsMerchandise && i.IsActive, false, i => i.Category);
var merchItems = allMerchItems var merchItems = allMerchItems
.OrderBy(i => i.Category.Name).ThenBy(i => i.DisplayOrder).ThenBy(i => i.Name) .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 }) .Select(i => new { i.Id, i.Name, i.SKU, CategoryName = i.Category.Name, i.DefaultPrice, i.RevenueAccountId })
@@ -2457,7 +2467,8 @@ public class InvoicesController : Controller
/// </summary> /// </summary>
private async Task PopulateBankAccountsAsync() private async Task PopulateBankAccountsAsync()
{ {
var accounts = await _unitOfWork.Accounts.FindAsync(a => a.IsActive var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var accounts = await _unitOfWork.Accounts.FindAsync(a => a.CompanyId == companyId && a.IsActive
&& (a.AccountSubType == AccountSubType.Cash || && (a.AccountSubType == AccountSubType.Cash ||
a.AccountSubType == AccountSubType.Checking || a.AccountSubType == AccountSubType.Checking ||
a.AccountSubType == AccountSubType.Savings)); a.AccountSubType == AccountSubType.Savings));
@@ -2472,7 +2483,7 @@ public class InvoicesController : Controller
private async Task<int?> GetCheckingAccountIdAsync(int companyId) private async Task<int?> GetCheckingAccountIdAsync(int companyId)
{ {
var acct = await _unitOfWork.Accounts.FirstOrDefaultAsync( var acct = await _unitOfWork.Accounts.FirstOrDefaultAsync(
a => a.IsActive && (a.AccountSubType == AccountSubType.Checking a => a.CompanyId == companyId && a.IsActive && (a.AccountSubType == AccountSubType.Checking
|| a.AccountSubType == AccountSubType.Cash)); || a.AccountSubType == AccountSubType.Cash));
return acct?.Id; return acct?.Id;
} }
@@ -2481,7 +2492,23 @@ public class InvoicesController : Controller
private async Task<int?> GetCustomerDepositsAccountIdAsync(int companyId) private async Task<int?> GetCustomerDepositsAccountIdAsync(int companyId)
{ {
var acct = await _unitOfWork.Accounts.FirstOrDefaultAsync( var acct = await _unitOfWork.Accounts.FirstOrDefaultAsync(
a => a.IsActive && a.AccountNumber == "2300"); 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");
return acct?.Id; return acct?.Id;
} }
@@ -2489,7 +2516,7 @@ public class InvoicesController : Controller
private async Task<int?> GetArAccountIdAsync(int companyId) private async Task<int?> GetArAccountIdAsync(int companyId)
{ {
var accounts = await _unitOfWork.Accounts.FindAsync( var accounts = await _unitOfWork.Accounts.FindAsync(
a => a.IsActive && a.AccountSubType == AccountSubType.AccountsReceivable); a => a.CompanyId == companyId && a.IsActive && a.AccountSubType == AccountSubType.AccountsReceivable);
return accounts.FirstOrDefault()?.Id; return accounts.FirstOrDefault()?.Id;
} }
@@ -2500,7 +2527,7 @@ public class InvoicesController : Controller
private async Task<int?> GetBadDebtAccountIdAsync(int companyId) private async Task<int?> GetBadDebtAccountIdAsync(int companyId)
{ {
var expenses = await _unitOfWork.Accounts.FindAsync( var expenses = await _unitOfWork.Accounts.FindAsync(
a => a.IsActive && a.AccountType == AccountType.Expense); a => a.CompanyId == companyId && a.IsActive && a.AccountType == AccountType.Expense);
return expenses.FirstOrDefault(a => a.Name.Contains("bad", StringComparison.OrdinalIgnoreCase) return expenses.FirstOrDefault(a => a.Name.Contains("bad", StringComparison.OrdinalIgnoreCase)
|| a.Name.Contains("debt", StringComparison.OrdinalIgnoreCase))?.Id || a.Name.Contains("debt", StringComparison.OrdinalIgnoreCase))?.Id
?? expenses.FirstOrDefault()?.Id; ?? expenses.FirstOrDefault()?.Id;
@@ -2531,9 +2558,9 @@ public class InvoicesController : Controller
private async Task<int?> ResolveSalesTaxAccountIdAsync(int companyId) private async Task<int?> ResolveSalesTaxAccountIdAsync(int companyId)
{ {
var taxAccount = await _unitOfWork.Accounts.FirstOrDefaultAsync( var taxAccount = await _unitOfWork.Accounts.FirstOrDefaultAsync(
a => a.AccountNumber == "2200" && a.IsActive); a => a.CompanyId == companyId && a.AccountNumber == "2200" && a.IsActive);
taxAccount ??= await _unitOfWork.Accounts.FirstOrDefaultAsync( taxAccount ??= await _unitOfWork.Accounts.FirstOrDefaultAsync(
a => a.AccountType == AccountType.Liability && a.IsActive && a.Name.ToLower().Contains("tax")); a => a.CompanyId == companyId && a.AccountType == AccountType.Liability && a.IsActive && a.Name.ToLower().Contains("tax"));
return taxAccount?.Id; return taxAccount?.Id;
} }
@@ -2545,9 +2572,9 @@ public class InvoicesController : Controller
private async Task<int?> GetSalesDiscountAccountIdAsync(int companyId) private async Task<int?> GetSalesDiscountAccountIdAsync(int companyId)
{ {
var discountAccount = await _unitOfWork.Accounts.FirstOrDefaultAsync( var discountAccount = await _unitOfWork.Accounts.FirstOrDefaultAsync(
a => a.AccountNumber == "4950" && a.IsActive); a => a.CompanyId == companyId && a.AccountNumber == "4950" && a.IsActive);
discountAccount ??= await _unitOfWork.Accounts.FirstOrDefaultAsync( discountAccount ??= await _unitOfWork.Accounts.FirstOrDefaultAsync(
a => a.AccountType == AccountType.Revenue && a.IsActive && a.Name.ToLower().Contains("discount")); a => a.CompanyId == companyId && a.AccountType == AccountType.Revenue && a.IsActive && a.Name.ToLower().Contains("discount"));
return discountAccount?.Id; return discountAccount?.Id;
} }
@@ -2555,7 +2582,7 @@ public class InvoicesController : Controller
private async Task<int?> GetGcLiabilityAccountIdAsync(int companyId) private async Task<int?> GetGcLiabilityAccountIdAsync(int companyId)
{ {
var acct = await _unitOfWork.Accounts.FirstOrDefaultAsync( var acct = await _unitOfWork.Accounts.FirstOrDefaultAsync(
a => a.IsActive && a.AccountNumber == "2500"); a => a.CompanyId == companyId && a.IsActive && a.AccountNumber == "2500");
return acct?.Id; return acct?.Id;
} }
@@ -2663,24 +2690,32 @@ public class InvoicesController : Controller
} }
await _unitOfWork.CompleteAsync(); 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."; TempData["Success"] = $"Refund of {dto.Amount:C} applied as store credit. Credit memo {memoNumber} created.";
} }
else else
{ {
// Adjust customer AR balance — they're owed money back // "Reverse the sale": a cash refund contra's the original sale instead of re-opening AR.
if (invoice.Customer != null) // 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
invoice.Customer.CurrentBalance -= dto.Amount; // sale is reversed via the contra accounts. The split is centralised in RefundAllocation
await _unitOfWork.Customers.UpdateAsync(invoice.Customer); // so LedgerService and FinancialReportService recompute the same way.
}
await _unitOfWork.CompleteAsync(); await _unitOfWork.CompleteAsync();
// GL: DR AR (un-collects the payment) / CR Bank (cash leaves). var (returnsPortion, taxPortion) = RefundAllocation.Split(dto.Amount, invoice.TaxAmount, invoice.Total);
// Mirrors how FinancialReportService accounts for refunds: var salesReturnsAccountId = await GetSalesReturnsAccountIdAsync(companyId);
// arTotalCredits -= refundTotal; refundsByAcct credits the bank account. var salesTaxAccountId = invoice.SalesTaxAccountId ?? await ResolveSalesTaxAccountIdAsync(companyId);
var arAccountId = await GetArAccountIdAsync(companyId);
await _accountBalanceService.DebitAsync(arAccountId, dto.Amount); await _accountBalanceService.DebitAsync(salesReturnsAccountId, returnsPortion);
if (taxPortion > 0)
await _accountBalanceService.DebitAsync(salesTaxAccountId, taxPortion);
await _accountBalanceService.CreditAsync(dto.DepositAccountId, dto.Amount); await _accountBalanceService.CreditAsync(dto.DepositAccountId, dto.Amount);
TempData["Success"] = $"Refund of {dto.Amount:C} recorded successfully. Please issue the refund manually."; TempData["Success"] = $"Refund of {dto.Amount:C} recorded successfully. Please issue the refund manually.";
@@ -2731,12 +2766,14 @@ public class InvoicesController : Controller
if (refund.RefundMethod == PaymentMethod.StoreCredit) if (refund.RefundMethod == PaymentMethod.StoreCredit)
{ {
// Cancel the linked CreditMemo and reverse the CreditBalance // Cancel the linked CreditMemo and reverse the unapplied store-credit remainder.
decimal creditReversed = refund.Amount;
if (refund.CreditMemoId.HasValue) if (refund.CreditMemoId.HasValue)
{ {
var memo = await _unitOfWork.CreditMemos.GetByIdAsync(refund.CreditMemoId.Value); var memo = await _unitOfWork.CreditMemos.GetByIdAsync(refund.CreditMemoId.Value);
if (memo != null && memo.Status == CreditMemoStatus.Active) if (memo != null && memo.Status == CreditMemoStatus.Active)
{ {
creditReversed = memo.Amount - memo.AmountApplied; // only the unapplied remainder
memo.Status = CreditMemoStatus.Voided; memo.Status = CreditMemoStatus.Voided;
memo.UpdatedAt = DateTime.UtcNow; memo.UpdatedAt = DateTime.UtcNow;
await _unitOfWork.CreditMemos.UpdateAsync(memo); await _unitOfWork.CreditMemos.UpdateAsync(memo);
@@ -2745,22 +2782,30 @@ public class InvoicesController : Controller
if (customer != null) if (customer != null)
{ {
customer.CreditBalance -= refund.Amount; customer.CreditBalance = Math.Max(0, customer.CreditBalance - creditReversed);
await _unitOfWork.Customers.UpdateAsync(customer); 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 else
{ {
// Reverse the AR balance adjustment // Reverse the "reverse the sale" posting: CR Sales Returns + CR Sales Tax Payable / DR Bank.
if (customer != null) // 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);
customer.CurrentBalance += refund.Amount; var salesReturnsAccountId = await GetSalesReturnsAccountIdAsync(refund.Invoice.CompanyId);
await _unitOfWork.Customers.UpdateAsync(customer); var salesTaxAccountId = refund.Invoice.SalesTaxAccountId ?? await ResolveSalesTaxAccountIdAsync(refund.Invoice.CompanyId);
}
// GL reversal: CR AR / DR Bank — mirrors the DR AR / CR Bank posted in IssueRefund. await _accountBalanceService.CreditAsync(salesReturnsAccountId, returnsPortion);
var arAccountId = await GetArAccountIdAsync(refund.Invoice.CompanyId); if (taxPortion > 0)
await _accountBalanceService.CreditAsync(arAccountId, refund.Amount); await _accountBalanceService.CreditAsync(salesTaxAccountId, taxPortion);
await _accountBalanceService.DebitAsync(refund.DepositAccountId, refund.Amount); await _accountBalanceService.DebitAsync(refund.DepositAccountId, refund.Amount);
} }
@@ -2823,6 +2868,14 @@ public class InvoicesController : Controller
} }
await _unitOfWork.CompleteAsync(); 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."; TempData["Success"] = $"Credit memo {memoNumber} for {dto.Amount:C} issued to customer.";
} }
catch (Exception ex) catch (Exception ex)
@@ -2909,9 +2962,11 @@ public class InvoicesController : Controller
} }
// GL: DR Sales Discounts 4950 / CR AR — same as CreditMemosController.Apply. // 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 arAccountId = await GetArAccountIdAsync(invoice.CompanyId);
var discountAcctId = await GetSalesDiscountAccountIdAsync(invoice.CompanyId); var customerCreditsAcctId = await GetCustomerCreditsAccountIdAsync(invoice.CompanyId);
await _accountBalanceService.DebitAsync(discountAcctId, applyAmount); await _accountBalanceService.DebitAsync(customerCreditsAcctId, applyAmount);
await _accountBalanceService.CreditAsync(arAccountId, applyAmount); await _accountBalanceService.CreditAsync(arAccountId, applyAmount);
await _unitOfWork.CompleteAsync(); await _unitOfWork.CompleteAsync();
@@ -2954,6 +3009,15 @@ public class InvoicesController : Controller
await _unitOfWork.Customers.UpdateAsync(memo.Customer); 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(); await _unitOfWork.CompleteAsync();
TempData["Success"] = "Credit memo voided."; TempData["Success"] = "Credit memo voided.";
return RedirectToAction(nameof(Details), new { id = invoiceId }); return RedirectToAction(nameof(Details), new { id = invoiceId });
@@ -213,24 +213,29 @@ public class JobsController : Controller
// Pill badge counts — always global (not scoped to current filter/page) // Pill badge counts — always global (not scoped to current filter/page)
var today = DateTime.Today; var today = DateTime.Today;
ViewBag.AllJobCount = await _unitOfWork.Jobs.CountAsync(); var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
ViewBag.AllJobCount = await _unitOfWork.Jobs.CountAsync(j => j.CompanyId == companyId);
ViewBag.ActiveCount = await _unitOfWork.Jobs.CountAsync(j => ViewBag.ActiveCount = await _unitOfWork.Jobs.CountAsync(j =>
j.JobStatus.StatusCode != AppConstants.StatusCodes.Job.Completed j.CompanyId == companyId
&& j.JobStatus.StatusCode != AppConstants.StatusCodes.Job.Completed
&& j.JobStatus.StatusCode != AppConstants.StatusCodes.Job.ReadyForPickup && j.JobStatus.StatusCode != AppConstants.StatusCodes.Job.ReadyForPickup
&& j.JobStatus.StatusCode != AppConstants.StatusCodes.Job.Delivered && j.JobStatus.StatusCode != AppConstants.StatusCodes.Job.Delivered
&& j.JobStatus.StatusCode != AppConstants.StatusCodes.Job.Cancelled); && j.JobStatus.StatusCode != AppConstants.StatusCodes.Job.Cancelled);
ViewBag.OverdueCount = await _unitOfWork.Jobs.CountAsync(j => ViewBag.OverdueCount = await _unitOfWork.Jobs.CountAsync(j =>
j.DueDate < today j.CompanyId == companyId
&& j.DueDate < today
&& 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.ReadyForPickup
&& j.JobStatus.StatusCode != AppConstants.StatusCodes.Job.Delivered && j.JobStatus.StatusCode != AppConstants.StatusCodes.Job.Delivered
&& j.JobStatus.StatusCode != AppConstants.StatusCodes.Job.Cancelled); && j.JobStatus.StatusCode != AppConstants.StatusCodes.Job.Cancelled);
ViewBag.CompletedCount = await _unitOfWork.Jobs.CountAsync(j => ViewBag.CompletedCount = await _unitOfWork.Jobs.CountAsync(j =>
j.JobStatus.StatusCode == AppConstants.StatusCodes.Job.Completed j.CompanyId == companyId &&
(j.JobStatus.StatusCode == AppConstants.StatusCodes.Job.Completed
|| j.JobStatus.StatusCode == AppConstants.StatusCodes.Job.ReadyForPickup || 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 => ViewBag.ReadyCount = await _unitOfWork.Jobs.CountAsync(j =>
j.JobStatus.StatusCode == AppConstants.StatusCodes.Job.ReadyForPickup); j.CompanyId == companyId
&& j.JobStatus.StatusCode == AppConstants.StatusCodes.Job.ReadyForPickup);
// Set ViewBag for sorting // Set ViewBag for sorting
ViewBag.SearchTerm = searchTerm; ViewBag.SearchTerm = searchTerm;
@@ -446,6 +451,9 @@ public class JobsController : Controller
ViewBag.JobInvoiceStatus = jobInvoice?.Status; ViewBag.JobInvoiceStatus = jobInvoice?.Status;
ViewBag.JobVoidedInvoices = voidedInvoices; 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 // Workers dropdown for inline assignment
await PopulateWorkersDropdown(); await PopulateWorkersDropdown();
@@ -2168,10 +2176,12 @@ public class JobsController : Controller
try try
{ {
var today = date?.Date ?? DateTime.Today; var today = date?.Date ?? DateTime.Today;
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
// Load all non-terminal statuses for the progress strip (excluding nav/hold/cancel) // Load all non-terminal statuses for the progress strip (excluding nav/hold/cancel)
var allStatusesEnum = await _unitOfWork.JobStatusLookups.FindAsync(s => var allStatusesEnum = await _unitOfWork.JobStatusLookups.FindAsync(s =>
s.StatusCode != AppConstants.StatusCodes.Job.OnHold && s.StatusCode != AppConstants.StatusCodes.Job.Cancelled s.CompanyId == companyId
&& 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.Delivered && s.StatusCode != AppConstants.StatusCodes.Job.Quoted
&& s.StatusCode != AppConstants.StatusCodes.Job.Pending && s.StatusCode != AppConstants.StatusCodes.Job.Approved); && s.StatusCode != AppConstants.StatusCodes.Job.Pending && s.StatusCode != AppConstants.StatusCodes.Job.Approved);
var allStatuses = allStatusesEnum.OrderBy(s => s.DisplayOrder).ToList(); var allStatuses = allStatusesEnum.OrderBy(s => s.DisplayOrder).ToList();
@@ -2181,7 +2191,7 @@ public class JobsController : Controller
// Get existing priority records for today // Get existing priority records for today
var existingPriorities = await _unitOfWork.JobDailyPriorities var existingPriorities = await _unitOfWork.JobDailyPriorities
.FindAsync(p => p.ScheduledDate.Date == today); .FindAsync(p => p.CompanyId == companyId && p.ScheduledDate.Date == today);
var priorityDict = existingPriorities.ToDictionary(p => p.JobId); var priorityDict = existingPriorities.ToDictionary(p => p.JobId);
@@ -2278,7 +2288,8 @@ public class JobsController : Controller
if (!companyId.HasValue) return RedirectToAction(nameof(Index)); if (!companyId.HasValue) return RedirectToAction(nameof(Index));
var allStatusesEnum = await _unitOfWork.JobStatusLookups.FindAsync(s => var allStatusesEnum = await _unitOfWork.JobStatusLookups.FindAsync(s =>
!s.IsTerminalStatus s.CompanyId == companyId.Value
&& !s.IsTerminalStatus
&& 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.Delivered);
var allStatuses = allStatusesEnum.OrderBy(s => s.DisplayOrder).ToList(); var allStatuses = allStatusesEnum.OrderBy(s => s.DisplayOrder).ToList();
@@ -2997,13 +3008,17 @@ public class JobsController : Controller
inventoryItem.QuantityOnHand -= deductNow; inventoryItem.QuantityOnHand -= deductNow;
await _unitOfWork.InventoryItems.UpdateAsync(inventoryItem); 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 var transaction = new InventoryTransaction
{ {
InventoryItemId = inventoryItem.Id, InventoryItemId = inventoryItem.Id,
TransactionType = InventoryTransactionType.JobUsage, TransactionType = InventoryTransactionType.JobUsage,
Quantity = -deductNow, Quantity = -deductNow,
UnitCost = inventoryItem.UnitCost, UnitCost = effectiveUnitCost,
TotalCost = inventoryItem.UnitCost * deductNow, TotalCost = effectiveUnitCost * deductNow,
TransactionDate = DateTime.UtcNow, TransactionDate = DateTime.UtcNow,
JobId = job.Id, JobId = job.Id,
Reference = job.JobNumber, Reference = job.JobNumber,
@@ -3015,7 +3030,7 @@ public class JobsController : Controller
if (inventoryItem.CogsAccountId.HasValue && inventoryItem.InventoryAccountId.HasValue) if (inventoryItem.CogsAccountId.HasValue && inventoryItem.InventoryAccountId.HasValue)
{ {
var cost = deductNow * (inventoryItem.AverageCost > 0 ? inventoryItem.AverageCost : inventoryItem.UnitCost); var cost = transaction.TotalCost;
await _accountBalanceService.DebitAsync(inventoryItem.CogsAccountId, cost); await _accountBalanceService.DebitAsync(inventoryItem.CogsAccountId, cost);
await _accountBalanceService.CreditAsync(inventoryItem.InventoryAccountId, cost); await _accountBalanceService.CreditAsync(inventoryItem.InventoryAccountId, cost);
} }
@@ -3492,7 +3507,8 @@ public class JobsController : Controller
efficiency = i.TransferEfficiency ?? 65m, efficiency = i.TransferEfficiency ?? 65m,
unitOfMeasure = i.UnitOfMeasure ?? "lbs", unitOfMeasure = i.UnitOfMeasure ?? "lbs",
categoryName = i.InventoryCategory!.DisplayName, categoryName = i.InventoryCategory!.DisplayName,
costPerLb = i.UnitCost, // Quote at the current catalog price when linked; fall back to their cost otherwise.
costPerLb = i.CatalogReferencePrice ?? i.UnitCost,
colorName = i.ColorName ?? i.Name, colorName = i.ColorName ?? i.Name,
colorCode = i.ColorCode ?? "", colorCode = i.ColorCode ?? "",
isIncoming = i.IsIncoming isIncoming = i.IsIncoming
@@ -57,13 +57,14 @@ public class JobsPriorityController : Controller
public async Task<IActionResult> Index(DateTime? date) public async Task<IActionResult> Index(DateTime? date)
{ {
var today = date?.Date ?? DateTime.Today; var today = date?.Date ?? DateTime.Today;
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
// Get all jobs scheduled for today with related data // Get all jobs scheduled for today with related data
var jobs = await _unitOfWork.Jobs.GetScheduledJobsForDateAsync(today); var jobs = await _unitOfWork.Jobs.GetScheduledJobsForDateAsync(today);
// Get existing priority records for today // Get existing priority records for today
var existingPriorities = await _unitOfWork.JobDailyPriorities var existingPriorities = await _unitOfWork.JobDailyPriorities
.FindAsync(p => p.ScheduledDate.Date == today); .FindAsync(p => p.CompanyId == companyId && p.ScheduledDate.Date == today);
var priorityDict = existingPriorities.ToDictionary(p => p.JobId); var priorityDict = existingPriorities.ToDictionary(p => p.JobId);
@@ -90,7 +91,6 @@ public class JobsPriorityController : Controller
.ToList(); .ToList();
// Get priorities and workers for modal options // Get priorities and workers for modal options
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var priorities = await _unitOfWork.JobPriorityLookups.FindAsync(p => p.CompanyId == companyId); var priorities = await _unitOfWork.JobPriorityLookups.FindAsync(p => p.CompanyId == companyId);
var workers = await _userManager.Users var workers = await _userManager.Users
.Where(u => u.CompanyId == companyId && u.IsActive && u.CompanyRole != null) .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) // Get maintenance records scheduled for today (Scheduled or InProgress)
var maintenanceItems = (await _unitOfWork.MaintenanceRecords.FindAsync( var maintenanceItems = (await _unitOfWork.MaintenanceRecords.FindAsync(
m => m.ScheduledDate.Date == today && m => m.CompanyId == companyId && m.ScheduledDate.Date == today &&
(m.Status == MaintenanceStatus.Scheduled || m.Status == MaintenanceStatus.InProgress), (m.Status == MaintenanceStatus.Scheduled || m.Status == MaintenanceStatus.InProgress),
false, false,
m => m.Equipment, m => m.AssignedUser)) m => m.Equipment, m => m.AssignedUser))
@@ -169,10 +169,11 @@ public class JobsPriorityController : Controller
} }
var today = DateTime.Today; var today = DateTime.Today;
var cid = _tenantContext.GetCurrentCompanyId() ?? 0;
// Get all existing priority records for today // Get all existing priority records for today
var existingPriorities = await _unitOfWork.JobDailyPriorities var existingPriorities = await _unitOfWork.JobDailyPriorities
.FindAsync(p => p.ScheduledDate.Date == today); .FindAsync(p => p.CompanyId == cid && p.ScheduledDate.Date == today);
var priorityDict = existingPriorities.ToDictionary(p => p.JobId); var priorityDict = existingPriorities.ToDictionary(p => p.JobId);
@@ -118,7 +118,7 @@ public class JournalEntriesController : Controller
// Load account names for lines // Load account names for lines
var accountIds = je.Lines.Select(l => l.AccountId).Distinct().ToList(); var accountIds = je.Lines.Select(l => l.AccountId).Distinct().ToList();
var accounts = await _unitOfWork.Accounts.FindAsync(a => accountIds.Contains(a.Id)); var accounts = await _unitOfWork.Accounts.FindAsync(a => a.CompanyId == je.CompanyId && accountIds.Contains(a.Id));
ViewBag.AccountMap = accounts.ToDictionary(a => a.Id, a => $"{a.AccountNumber} {a.Name}"); ViewBag.AccountMap = accounts.ToDictionary(a => a.Id, a => $"{a.AccountNumber} {a.Name}");
// Reversal metadata // Reversal metadata
@@ -196,6 +196,113 @@ public class JournalEntriesController : Controller
return RedirectToAction(nameof(Details), new { id }); return RedirectToAction(nameof(Details), new { id });
} }
// ── Sales Tax Remittance ───────────────────────────────────────────────────
/// <summary>
/// Form to record a sales tax payment to the tax authority. Shows the current Sales Tax Payable
/// (2200) liability and a bank-account picker. Relieves the liability that invoices accumulate.
/// </summary>
// GET: /JournalEntries/SalesTaxPayment
public async Task<IActionResult> SalesTaxPayment()
{
if (!AllowAccounting()) return RedirectToAction("Landing", "Reports");
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var taxAcct = (await _unitOfWork.Accounts.FindAsync(
a => a.CompanyId == companyId && a.AccountNumber == "2200" && a.IsActive)).FirstOrDefault();
ViewBag.TaxLiability = taxAcct?.CurrentBalance ?? 0m;
ViewBag.TaxAccountFound = taxAcct != null;
var banks = await _unitOfWork.Accounts.FindAsync(a => a.CompanyId == companyId && a.IsActive
&& (a.AccountSubType == AccountSubType.Checking
|| a.AccountSubType == AccountSubType.Savings
|| a.AccountSubType == AccountSubType.Cash));
ViewBag.BankAccounts = banks.OrderBy(a => a.AccountNumber)
.Select(a => new SelectListItem($"{a.AccountNumber} {a.Name}", a.Id.ToString())).ToList();
return View();
}
/// <summary>
/// Records a sales tax remittance as a posted journal entry: DR Sales Tax Payable (2200) / CR the
/// chosen bank account. Honors the period lock. The reporting already accounts for posted JE lines,
/// so this is all that's needed to relieve the liability.
/// </summary>
// POST: /JournalEntries/SalesTaxPayment
[HttpPost]
[Authorize(Policy = AppConstants.Policies.CanManageJobs)]
[ValidateAntiForgeryToken]
public async Task<IActionResult> SalesTaxPayment(decimal amount, DateTime paymentDate, int bankAccountId, string? reference)
{
if (!AllowAccounting()) return RedirectToAction("Landing", "Reports");
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
if (amount <= 0)
{
TempData["Error"] = "Enter a payment amount greater than zero.";
return RedirectToAction(nameof(SalesTaxPayment));
}
var taxAcct = (await _unitOfWork.Accounts.FindAsync(
a => a.CompanyId == companyId && a.AccountNumber == "2200" && a.IsActive)).FirstOrDefault();
if (taxAcct == null)
{
TempData["Error"] = "No active Sales Tax Payable (2200) account found in your chart of accounts.";
return RedirectToAction(nameof(SalesTaxPayment));
}
// 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 ────────────────────────────────────────────────────────────── // ── Reverse ──────────────────────────────────────────────────────────────
[HttpPost] [HttpPost]
@@ -367,7 +474,8 @@ public class JournalEntriesController : Controller
private async Task PopulateAccountDropdownAsync() private async Task PopulateAccountDropdownAsync()
{ {
var accounts = await _unitOfWork.Accounts.FindAsync(a => a.IsActive); var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var accounts = await _unitOfWork.Accounts.FindAsync(a => a.CompanyId == companyId && a.IsActive);
ViewBag.AccountSelectList = accounts ViewBag.AccountSelectList = accounts
.OrderBy(a => a.AccountNumber) .OrderBy(a => a.AccountNumber)
.Select(a => new SelectListItem .Select(a => new SelectListItem
@@ -582,7 +582,9 @@ public class KioskController : Controller
[Authorize] [Authorize]
public async Task<IActionResult> Intakes(string? filter) public async Task<IActionResult> Intakes(string? filter)
{ {
var sessions = await _unitOfWork.KioskSessions.GetAllAsync(false, var companyId = GetCurrentCompanyId();
var sessions = await _unitOfWork.KioskSessions.FindAsync(
s => s.CompanyId == companyId, false,
s => s.LinkedCustomer, s => s.LinkedCustomer,
s => s.LinkedJob); s => s.LinkedJob);
@@ -11,7 +11,9 @@ using PowderCoating.Web.Hubs;
namespace PowderCoating.Web.Controllers; namespace PowderCoating.Web.Controllers;
[Authorize] // 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)]
public class OvenSchedulerController : Controller public class OvenSchedulerController : Controller
{ {
private readonly IUnitOfWork _unitOfWork; private readonly IUnitOfWork _unitOfWork;
@@ -61,16 +63,17 @@ public class OvenSchedulerController : Controller
public async Task<IActionResult> Index(DateTime? date, string goal = "maximize_throughput") public async Task<IActionResult> Index(DateTime? date, string goal = "maximize_throughput")
{ {
var scheduledDate = date?.Date ?? DateTime.Today; var scheduledDate = date?.Date ?? DateTime.Today;
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
// Load active Named Ovens — filter IsActive at database level // Load active Named Ovens — filter IsActive at database level
var ovenCosts = (await _unitOfWork.OvenCosts.FindAsync(o => o.IsActive)) var ovenCosts = (await _unitOfWork.OvenCosts.FindAsync(o => o.CompanyId == companyId && o.IsActive))
.OrderBy(o => o.DisplayOrder).ThenBy(o => o.Label) .OrderBy(o => o.DisplayOrder).ThenBy(o => o.Label)
.ToList(); .ToList();
// Load batches for the selected date — filter at database level with includes // Load batches for the selected date — filter at database level with includes
var scheduledDateEnd = scheduledDate.AddDays(1); var scheduledDateEnd = scheduledDate.AddDays(1);
var batches = (await _unitOfWork.OvenBatches.FindAsync( var batches = (await _unitOfWork.OvenBatches.FindAsync(
b => b.ScheduledDate >= scheduledDate && b.ScheduledDate < scheduledDateEnd b => b.CompanyId == companyId && b.ScheduledDate >= scheduledDate && b.ScheduledDate < scheduledDateEnd
&& b.Status != OvenBatchStatus.Cancelled, && b.Status != OvenBatchStatus.Cancelled,
false, false,
b => b.OvenCost, b => b.Items)) b => b.OvenCost, b => b.Items))
@@ -98,7 +101,7 @@ public class OvenSchedulerController : Controller
// Load jobs in the queue — filter by status at database level // Load jobs in the queue — filter by status at database level
var queueJobs = (await _unitOfWork.Jobs.FindAsync( var queueJobs = (await _unitOfWork.Jobs.FindAsync(
j => j.JobStatus != null && QueueableStatuses.Contains(j.JobStatus.StatusCode), j => j.CompanyId == companyId && j.JobStatus != null && QueueableStatuses.Contains(j.JobStatus.StatusCode),
false, false,
j => j.Customer, j => j.JobStatus, j => j.JobPriority, j => j.JobItems)) j => j.Customer, j => j.JobStatus, j => j.JobPriority, j => j.JobItems))
.ToList(); .ToList();
@@ -126,14 +129,14 @@ public class OvenSchedulerController : Controller
// Determine which coats are already scheduled — filter out removed/cancelled at database level // Determine which coats are already scheduled — filter out removed/cancelled at database level
var scheduledCoatIds = (await _unitOfWork.OvenBatchItems.FindAsync( var scheduledCoatIds = (await _unitOfWork.OvenBatchItems.FindAsync(
i => i.Status != OvenBatchItemStatus.Removed && i.Batch.Status != OvenBatchStatus.Cancelled, i => i.CompanyId == companyId && i.Status != OvenBatchItemStatus.Removed && i.Batch.Status != OvenBatchStatus.Cancelled,
false, false,
i => i.Batch)) i => i.Batch))
.Select(i => i.JobItemCoatId) .Select(i => i.JobItemCoatId)
.ToHashSet(); .ToHashSet();
// Get company defaults // Get company defaults
var companyCosts = await _unitOfWork.CompanyOperatingCosts.FirstOrDefaultAsync(c => true); var companyCosts = await _unitOfWork.CompanyOperatingCosts.FirstOrDefaultAsync(c => c.CompanyId == companyId);
var defaultCycleMinutes = companyCosts?.DefaultOvenCycleMinutes ?? 45; var defaultCycleMinutes = companyCosts?.DefaultOvenCycleMinutes ?? 45;
// Build the view model // Build the view model
@@ -1,5 +1,6 @@
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using PowderCoating.Application.Constants;
using PowderCoating.Application.DTOs.Common; using PowderCoating.Application.DTOs.Common;
using PowderCoating.Application.DTOs.Inventory; using PowderCoating.Application.DTOs.Inventory;
using PowderCoating.Application.Interfaces; using PowderCoating.Application.Interfaces;
@@ -17,17 +18,28 @@ public class PowderCatalogController : Controller
{ {
private const decimal DefaultTransferEfficiency = 65m; private const decimal DefaultTransferEfficiency = 65m;
private const string JsonImportSource = "Manual JSON Import";
private readonly IUnitOfWork _unitOfWork; private readonly IUnitOfWork _unitOfWork;
private readonly IInventoryAiLookupService _aiLookupService; private readonly IInventoryAiLookupService _aiLookupService;
private readonly IColumbiaCatalogSyncService _columbiaSyncService;
private readonly IPowderCatalogUpsertService _upsertService;
private readonly IPlatformSettingsService _platformSettings;
private readonly ILogger<PowderCatalogController> _logger; private readonly ILogger<PowderCatalogController> _logger;
public PowderCatalogController( public PowderCatalogController(
IUnitOfWork unitOfWork, IUnitOfWork unitOfWork,
IInventoryAiLookupService aiLookupService, IInventoryAiLookupService aiLookupService,
IColumbiaCatalogSyncService columbiaSyncService,
IPowderCatalogUpsertService upsertService,
IPlatformSettingsService platformSettings,
ILogger<PowderCatalogController> logger) ILogger<PowderCatalogController> logger)
{ {
_unitOfWork = unitOfWork; _unitOfWork = unitOfWork;
_aiLookupService = aiLookupService; _aiLookupService = aiLookupService;
_columbiaSyncService = columbiaSyncService;
_upsertService = upsertService;
_platformSettings = platformSettings;
_logger = logger; _logger = logger;
} }
@@ -135,6 +147,11 @@ public class PowderCatalogController : Controller
} }
}; };
// Columbia sync status for the admin panel (last run + master switch).
ViewBag.ColumbiaSyncEnabled = await _platformSettings.GetBoolAsync(ColumbiaIntegrationConstants.SettingEnabled);
ViewBag.ColumbiaLastSyncedAt = await _platformSettings.GetAsync(ColumbiaIntegrationConstants.SettingLastSyncedAt);
ViewBag.ColumbiaLastResult = await _platformSettings.GetAsync(ColumbiaIntegrationConstants.SettingLastResult);
return View(vm); return View(vm);
} }
@@ -422,6 +439,78 @@ public class PowderCatalogController : Controller
return Json(results); return Json(results);
} }
/// <summary>
/// Manually triggers a full Columbia Coatings catalog sync (SuperAdmin only). Bypasses the
/// scheduled interval. Reports the run outcome via TempData on the catalog index.
/// </summary>
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> SyncColumbia(CancellationToken cancellationToken)
{
var result = await _columbiaSyncService.RunSyncAsync(cancellationToken);
if (result.Success)
TempData["Success"] = $"Columbia sync complete - {result.Summary}";
else
TempData["Error"] = $"Columbia sync failed: {result.ErrorMessage}";
return RedirectToAction(nameof(Index));
}
/// <summary>
/// Right-to-delete: removes every catalog record sourced from the Columbia Coatings API
/// (regardless of derived manufacturer, since PPG/KP products were served through that feed)
/// and nulls any inventory links to them across all tenants. The shops' own inventory stock
/// records survive — only the catalog link and discontinued badge are lost. SuperAdmin only.
/// </summary>
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> PurgeColumbiaData()
{
var sourced = (await _unitOfWork.PowderCatalog.FindAsync(
p => p.Source == ColumbiaIntegrationConstants.SourceName)).ToList();
if (sourced.Count == 0)
{
TempData["Error"] = "There is no Columbia Coatings API data to remove.";
return RedirectToAction(nameof(Index));
}
var ids = sourced.Select(p => p.Id).ToList();
// Null the inventory links across ALL tenants (platform-level purge). A tenant's stock
// record is their data and must survive — it keeps its add-time snapshot, losing only the
// live catalog link.
var linked = (await _unitOfWork.InventoryItems.FindAsync(
i => i.PowderCatalogItemId.HasValue && ids.Contains(i.PowderCatalogItemId.Value),
ignoreQueryFilters: true)).ToList();
foreach (var inv in linked)
{
inv.PowderCatalogItemId = null;
await _unitOfWork.InventoryItems.UpdateAsync(inv);
}
foreach (var p in sourced)
await _unitOfWork.PowderCatalog.DeleteAsync(p);
await _unitOfWork.CompleteAsync();
// Reset sync tracking so the admin panel reflects the purge.
await _platformSettings.SetAsync(ColumbiaIntegrationConstants.SettingLastSyncedAt, null, "Columbia Purge");
await _platformSettings.SetAsync(
ColumbiaIntegrationConstants.SettingLastResult,
$"Purged {sourced.Count:N0} records on {DateTime.UtcNow:yyyy-MM-dd}",
"Columbia Purge");
_logger.LogWarning(
"Columbia data purge: deleted {Count} catalog records, unlinked {Linked} inventory items.",
sourced.Count, linked.Count);
TempData["Success"] =
$"Removed {sourced.Count:N0} Columbia Coatings catalog record(s) and unlinked " +
$"{linked.Count:N0} inventory item(s). Inventory stock was preserved.";
return RedirectToAction(nameof(Index));
}
// Private helpers // Private helpers
private async Task ApplyTdsCureFallbackAsync(InventoryAiLookupResult result, string? colorName) private async Task ApplyTdsCureFallbackAsync(InventoryAiLookupResult result, string? colorName)
@@ -449,13 +538,10 @@ public class PowderCatalogController : Controller
return new PowderCatalogImportResult { Success = false, ErrorMessage = "JSON must have a top-level 'results' array." }; return new PowderCatalogImportResult { Success = false, ErrorMessage = "JSON must have a top-level 'results' array." };
} }
// Load existing records for this vendor into a lookup dictionary // Map the scrape format to catalog items, then hand off to the shared upsert path (same
var existing = (await _unitOfWork.PowderCatalog.FindAsync(p => p.VendorName == vendorName)) // one the Columbia API sync uses) so there is a single insert/update/diff implementation.
.ToDictionary(p => p.Sku, StringComparer.OrdinalIgnoreCase); var mapped = new List<PowderCatalogItem>();
int skipped = 0, errors = 0;
var now = DateTime.UtcNow;
int inserted = 0, updated = 0, skipped = 0, errors = 0;
var toAdd = new List<PowderCatalogItem>();
foreach (var item in resultsEl.EnumerateArray()) foreach (var item in resultsEl.EnumerateArray())
{ {
@@ -469,49 +555,21 @@ public class PowderCatalogController : Controller
continue; continue;
} }
var rawDesc = item.GetStringOrNull("description"); mapped.Add(new PowderCatalogItem
var cleanDesc = StripBoilerplate(rawDesc);
var unitPrice = ExtractBasePrice(item);
var priceTiersJson = item.TryGetProperty("price_tiers", out var tiersEl)
? tiersEl.GetRawText()
: null;
if (existing.TryGetValue(sku, out var record))
{
record.ColorName = colorName;
record.Description = cleanDesc;
record.UnitPrice = unitPrice;
record.PriceTiersJson = priceTiersJson;
record.ImageUrl = item.GetStringOrNull("sample_image_url");
record.SdsUrl = item.GetStringOrNull("safety_data_sheet_url");
record.TdsUrl = item.GetStringOrNull("technical_data_sheet_url");
record.ApplicationGuideUrl = item.GetStringOrNull("application_guide_url");
record.ProductUrl = item.GetStringOrNull("product_url");
record.UpdatedAt = now;
record.LastSyncedAt = now;
await _unitOfWork.PowderCatalog.UpdateAsync(record);
updated++;
}
else
{
toAdd.Add(new PowderCatalogItem
{ {
VendorName = vendorName, VendorName = vendorName,
Source = JsonImportSource,
Sku = sku, Sku = sku,
ColorName = colorName, ColorName = colorName,
Description = cleanDesc, Description = StripBoilerplate(item.GetStringOrNull("description")),
UnitPrice = unitPrice, UnitPrice = ExtractBasePrice(item),
PriceTiersJson = priceTiersJson, PriceTiersJson = item.TryGetProperty("price_tiers", out var tiersEl) ? tiersEl.GetRawText() : null,
ImageUrl = item.GetStringOrNull("sample_image_url"), ImageUrl = item.GetStringOrNull("sample_image_url"),
SdsUrl = item.GetStringOrNull("safety_data_sheet_url"), SdsUrl = item.GetStringOrNull("safety_data_sheet_url"),
TdsUrl = item.GetStringOrNull("technical_data_sheet_url"), TdsUrl = item.GetStringOrNull("technical_data_sheet_url"),
ApplicationGuideUrl = item.GetStringOrNull("application_guide_url"), ApplicationGuideUrl = item.GetStringOrNull("application_guide_url"),
ProductUrl = item.GetStringOrNull("product_url"), ProductUrl = item.GetStringOrNull("product_url"),
CreatedAt = now,
LastSyncedAt = now
}); });
inserted++;
}
} }
catch (Exception ex) catch (Exception ex)
{ {
@@ -520,17 +578,14 @@ public class PowderCatalogController : Controller
} }
} }
if (toAdd.Any()) var upsert = await _upsertService.UpsertAsync(mapped, DateTime.UtcNow);
await _unitOfWork.PowderCatalog.AddRangeAsync(toAdd);
await _unitOfWork.CompleteAsync();
return new PowderCatalogImportResult return new PowderCatalogImportResult
{ {
Success = true, Success = true,
Inserted = inserted, Inserted = upsert.Inserted,
Updated = updated, Updated = upsert.Updated,
Skipped = skipped, Skipped = skipped + upsert.Skipped,
Errors = errors Errors = errors
}; };
} }
@@ -68,7 +68,8 @@ public class PricingTiersController : Controller
return View(dto); return View(dto);
// Check for duplicate name // Check for duplicate name
var existing = await _unitOfWork.PricingTiers.FindAsync(t => t.TierName == dto.TierName); var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var existing = await _unitOfWork.PricingTiers.FindAsync(t => t.CompanyId == companyId && t.TierName == dto.TierName);
if (existing.Any()) if (existing.Any())
{ {
ModelState.AddModelError(nameof(dto.TierName), "A tier with this name already exists."); ModelState.AddModelError(nameof(dto.TierName), "A tier with this name already exists.");
@@ -111,8 +112,9 @@ public class PricingTiersController : Controller
if (entity == null) return NotFound(); if (entity == null) return NotFound();
// Check for duplicate name (excluding this record) // Check for duplicate name (excluding this record)
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var duplicate = await _unitOfWork.PricingTiers.FindAsync( var duplicate = await _unitOfWork.PricingTiers.FindAsync(
t => t.TierName == dto.TierName && t.Id != dto.Id); t => t.CompanyId == companyId && t.TierName == dto.TierName && t.Id != dto.Id);
if (duplicate.Any()) if (duplicate.Any())
{ {
ModelState.AddModelError(nameof(dto.TierName), "A tier with this name already exists."); ModelState.AddModelError(nameof(dto.TierName), "A tier with this name already exists.");
@@ -138,7 +140,8 @@ public class PricingTiersController : Controller
if (entity == null) return NotFound(); if (entity == null) return NotFound();
// Block delete if customers are assigned to this tier // Block delete if customers are assigned to this tier
var assignedCustomers = await _unitOfWork.Customers.FindAsync(c => c.PricingTierId == id); var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var assignedCustomers = await _unitOfWork.Customers.FindAsync(c => c.CompanyId == companyId && c.PricingTierId == id);
if (assignedCustomers.Any()) if (assignedCustomers.Any())
{ {
TempData["ErrorMessage"] = $"Cannot delete '{entity.TierName}' — {assignedCustomers.Count()} customer(s) are assigned to it. Reassign them first."; TempData["ErrorMessage"] = $"Cannot delete '{entity.TierName}' — {assignedCustomers.Count()} customer(s) are assigned to it. Reassign them first.";
@@ -302,6 +302,9 @@ public class QuotesController : Controller
var quoteDto = _mapper.Map<QuoteDto>(quote); 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 // Get customer info if exists
if (quote.CustomerId.HasValue) if (quote.CustomerId.HasValue)
{ {
@@ -2545,7 +2548,8 @@ public class QuotesController : Controller
efficiency = i.TransferEfficiency ?? 65m, efficiency = i.TransferEfficiency ?? 65m,
unitOfMeasure = i.UnitOfMeasure ?? "lbs", unitOfMeasure = i.UnitOfMeasure ?? "lbs",
categoryName = i.InventoryCategory!.DisplayName, categoryName = i.InventoryCategory!.DisplayName,
costPerLb = i.UnitCost, // Quote at the current catalog price when linked; fall back to their cost otherwise.
costPerLb = i.CatalogReferencePrice ?? i.UnitCost,
colorName = i.ColorName ?? i.Name, colorName = i.ColorName ?? i.Name,
colorCode = i.ColorCode ?? "", colorCode = i.ColorCode ?? "",
isIncoming = i.IsIncoming isIncoming = i.IsIncoming
@@ -3424,7 +3428,7 @@ public class QuotesController : Controller
try try
{ {
var powders = await _unitOfWork.InventoryItems.FindAsync(i => var powders = await _unitOfWork.InventoryItems.FindAsync(i =>
i.Category != null && i.Category.ToLower().Contains("powder") && i.UnitCost > 0); i.CompanyId == companyId && i.Category != null && i.Category.ToLower().Contains("powder") && i.UnitCost > 0);
avgPowderCost = powders.Any() ? powders.Average(p => p.UnitCost) : 8m; avgPowderCost = powders.Any() ? powders.Average(p => p.UnitCost) : 8m;
} }
catch catch
@@ -3518,7 +3522,7 @@ public class QuotesController : Controller
try try
{ {
var powders = await _unitOfWork.InventoryItems.FindAsync(i => var powders = await _unitOfWork.InventoryItems.FindAsync(i =>
i.Category != null && i.Category.ToLower().Contains("powder") && i.UnitCost > 0); i.CompanyId == companyId && i.Category != null && i.Category.ToLower().Contains("powder") && i.UnitCost > 0);
avgPowderCost = powders.Any() ? powders.Average(p => p.UnitCost) : 8m; avgPowderCost = powders.Any() ? powders.Average(p => p.UnitCost) : 8m;
} }
catch { avgPowderCost = 8m; } catch { avgPowderCost = 8m; }
@@ -3613,7 +3617,7 @@ public class QuotesController : Controller
// Pull recent accepted predictions (user didn't override) as few-shot calibration examples // Pull recent accepted predictions (user didn't override) as few-shot calibration examples
var allPredictions = await _unitOfWork.AiItemPredictions.FindAsync( var allPredictions = await _unitOfWork.AiItemPredictions.FindAsync(
p => !p.UserOverrodeEstimate && p.PredictedSurfaceAreaSqFt > 0 && p.PredictedUnitPrice > 0); p => p.CompanyId == companyId && !p.UserOverrodeEstimate && p.PredictedSurfaceAreaSqFt > 0 && p.PredictedUnitPrice > 0);
context.AcceptedExamples = allPredictions context.AcceptedExamples = allPredictions
.OrderByDescending(p => p.CreatedAt) .OrderByDescending(p => p.CreatedAt)
@@ -3656,9 +3660,11 @@ public class QuotesController : Controller
{ {
var sqFtMin = sqFt * 0.4m; var sqFtMin = sqFt * 0.4m;
var sqFtMax = sqFt * 2.5m; var sqFtMax = sqFt * 2.5m;
var companyId = (await _userManager.GetUserAsync(User))?.CompanyId ?? 0;
var matches = await _unitOfWork.JobItems.FindAsync( var matches = await _unitOfWork.JobItems.FindAsync(
ji => ji.Complexity == complexity ji => ji.CompanyId == companyId
&& ji.Complexity == complexity
&& ji.SurfaceAreaSqFt >= sqFtMin && ji.SurfaceAreaSqFt >= sqFtMin
&& ji.SurfaceAreaSqFt <= sqFtMax && ji.SurfaceAreaSqFt <= sqFtMax
&& ji.UnitPrice > 0 && ji.UnitPrice > 0
@@ -3666,7 +3672,7 @@ public class QuotesController : Controller
var jobIds = matches.Select(ji => ji.JobId).Distinct().ToList(); var jobIds = matches.Select(ji => ji.JobId).Distinct().ToList();
var completedStatusIds = (await _unitOfWork.JobStatusLookups.FindAsync( var completedStatusIds = (await _unitOfWork.JobStatusLookups.FindAsync(
s => s.StatusCode == AppConstants.StatusCodes.Job.Completed || s.StatusCode == AppConstants.StatusCodes.Job.Delivered)) s => s.CompanyId == companyId && (s.StatusCode == AppConstants.StatusCodes.Job.Completed || s.StatusCode == AppConstants.StatusCodes.Job.Delivered)))
.Select(s => s.Id).ToHashSet(); .Select(s => s.Id).ToHashSet();
var completedJobs = await _unitOfWork.Jobs.FindAsync( var completedJobs = await _unitOfWork.Jobs.FindAsync(
j => jobIds.Contains(j.Id) && completedStatusIds.Contains(j.JobStatusId)); j => jobIds.Contains(j.Id) && completedStatusIds.Contains(j.JobStatusId));
@@ -590,7 +590,8 @@ public class ReportsController : Controller
// === POWDER USAGE ANALYTICS === // === POWDER USAGE ANALYTICS ===
var powderTransactions = (await _unitOfWork.InventoryTransactions var powderTransactions = (await _unitOfWork.InventoryTransactions
.FindAsync(t => t.TransactionType == InventoryTransactionType.JobUsage .FindAsync(t => t.CompanyId == companyId
&& t.TransactionType == InventoryTransactionType.JobUsage
&& t.TransactionDate >= startDate, && t.TransactionDate >= startDate,
false, false,
t => t.InventoryItem)) t => t.InventoryItem))
@@ -1250,6 +1251,20 @@ public class ReportsController : Controller
return View(dto); 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> /// <summary>
/// PDF export of the Trial Balance report. Same inline/attachment pattern as other PDF actions. /// PDF export of the Trial Balance report. Same inline/attachment pattern as other PDF actions.
/// Gated behind <see cref="AllowAccounting"/>. /// Gated behind <see cref="AllowAccounting"/>.
@@ -2498,7 +2513,7 @@ public class ReportsController : Controller
var reportYear = year ?? DateTime.Now.Year; var reportYear = year ?? DateTime.Now.Year;
// Load all budgets for the year for the selector // Load all budgets for the year for the selector
var allBudgets = (await _unitOfWork.Budgets.FindAsync(b => b.FiscalYear == reportYear)) var allBudgets = (await _unitOfWork.Budgets.FindAsync(b => b.CompanyId == companyId && b.FiscalYear == reportYear))
.OrderBy(b => b.Name).ToList(); .OrderBy(b => b.Name).ToList();
Core.Entities.Budget? budget = null; Core.Entities.Budget? budget = null;
@@ -2506,10 +2521,10 @@ public class ReportsController : Controller
budget = await _unitOfWork.Budgets.GetByIdAsync(budgetId.Value, false, b => b.Lines); budget = await _unitOfWork.Budgets.GetByIdAsync(budgetId.Value, false, b => b.Lines);
budget ??= (await _unitOfWork.Budgets.FindAsync( budget ??= (await _unitOfWork.Budgets.FindAsync(
b => b.FiscalYear == reportYear && b.IsDefault, false, b => b.Lines)).FirstOrDefault(); b => b.CompanyId == companyId && b.FiscalYear == reportYear && b.IsDefault, false, b => b.Lines)).FirstOrDefault();
budget ??= (await _unitOfWork.Budgets.FindAsync( budget ??= (await _unitOfWork.Budgets.FindAsync(
b => b.FiscalYear == reportYear, false, b => b.Lines)).FirstOrDefault(); b => b.CompanyId == companyId && b.FiscalYear == reportYear, false, b => b.Lines)).FirstOrDefault();
ViewBag.ReportYear = reportYear; ViewBag.ReportYear = reportYear;
ViewBag.Budget = budget; ViewBag.Budget = budget;
@@ -2544,7 +2559,7 @@ public class ReportsController : Controller
// Load account metadata for budget lines // Load account metadata for budget lines
var accountIds = budget.Lines.Select(l => l.AccountId).Distinct().ToList(); var accountIds = budget.Lines.Select(l => l.AccountId).Distinct().ToList();
var accounts = (await _unitOfWork.Accounts.FindAsync(a => accountIds.Contains(a.Id))) var accounts = (await _unitOfWork.Accounts.FindAsync(a => a.CompanyId == budget.CompanyId && accountIds.Contains(a.Id)))
.ToDictionary(a => a.Id); .ToDictionary(a => a.Id);
var rows = new List<BudgetVsActualRow>(); var rows = new List<BudgetVsActualRow>();
@@ -2585,7 +2600,7 @@ public class ReportsController : Controller
var periodEnd = new DateTime(reportYear, 12, 31, 23, 59, 59, DateTimeKind.Utc); var periodEnd = new DateTime(reportYear, 12, 31, 23, 59, 59, DateTimeKind.Utc);
// Load 1099-eligible vendors // Load 1099-eligible vendors
var vendors = (await _unitOfWork.Vendors.FindAsync(v => v.Is1099Vendor)).ToList(); var vendors = (await _unitOfWork.Vendors.FindAsync(v => v.CompanyId == companyId && v.Is1099Vendor)).ToList();
var rows = new List<Vendor1099Row>(); var rows = new List<Vendor1099Row>();
@@ -134,7 +134,8 @@ public class TaxRatesController : Controller
/// </summary> /// </summary>
private async Task ClearOtherDefaultsAsync(int exceptId) private async Task ClearOtherDefaultsAsync(int exceptId)
{ {
var others = await _unitOfWork.TaxRates.FindAsync(r => r.IsDefault && r.Id != exceptId); var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var others = await _unitOfWork.TaxRates.FindAsync(r => r.CompanyId == companyId && r.IsDefault && r.Id != exceptId);
foreach (var r in others) foreach (var r in others)
r.IsDefault = false; r.IsDefault = false;
} }
@@ -3,16 +3,21 @@ using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Rendering; using Microsoft.AspNetCore.Mvc.Rendering;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using PowderCoating.Application.DTOs.Import;
using PowderCoating.Application.Interfaces; using PowderCoating.Application.Interfaces;
using PowderCoating.Core.Entities; using PowderCoating.Core.Entities;
using PowderCoating.Core.Enums; using PowderCoating.Core.Enums;
using PowderCoating.Core.Interfaces; using PowderCoating.Core.Interfaces;
using PowderCoating.Infrastructure.Data; using PowderCoating.Infrastructure.Data;
using PowderCoating.Shared.Constants;
using System.Security.Claims; using System.Security.Claims;
namespace PowderCoating.Web.Controllers; namespace PowderCoating.Web.Controllers;
[Authorize] // 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)]
public class ToolsController : Controller public class ToolsController : Controller
{ {
private readonly IUnitOfWork _unitOfWork; private readonly IUnitOfWork _unitOfWork;
@@ -1394,6 +1399,53 @@ 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> /// <summary>
/// Downloads a blank CSV template for the native payment bulk import. /// Downloads a blank CSV template for the native payment bulk import.
/// Columns match the native ExportPaymentsCsv output for round-trip compatibility. /// Columns match the native ExportPaymentsCsv output for round-trip compatibility.
@@ -1406,6 +1458,90 @@ public class ToolsController : Controller
return File(csvBytes, "text/csv", "payment_import_template.csv"); 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> /// <summary>
/// Bulk-imports payment records from a native CSV file. Invoices are resolved by InvoiceNumber. /// 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 /// Duplicate payments (same invoice + date + amount) are skipped. Updates the invoice AmountPaid
@@ -2044,7 +2180,7 @@ public class ToolsController : Controller
} }
// 11. Invoices // 11. Invoices
var invoices = await _unitOfWork.Invoices.FindAsync(i => i.CompanyId == companyId.Value, false, i => i.Customer, i => i.Job); var invoices = await _unitOfWork.Invoices.FindAsync(i => i.CompanyId == companyId.Value, false, i => i.Customer, i => i.Job, i => i.InvoiceItems);
var invoicesCsv = GenerateInvoicesCsv(invoices); var invoicesCsv = GenerateInvoicesCsv(invoices);
var invoicesEntry = archive.CreateEntry($"invoices_{timestamp}.csv"); var invoicesEntry = archive.CreateEntry($"invoices_{timestamp}.csv");
using (var entryStream = invoicesEntry.Open()) using (var entryStream = invoicesEntry.Open())
@@ -2063,6 +2199,17 @@ public class ToolsController : Controller
await writer.WriteAsync(accountsCsv); 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 // 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 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); var expensesCsv = GenerateExpensesCsv(expenses);
@@ -2074,7 +2221,7 @@ public class ToolsController : Controller
} }
// 14. Payments // 14. Payments
var payments = await _unitOfWork.Payments.FindAsync(p => p.CompanyId == companyId.Value, false, p => p.Invoice); var payments = await _unitOfWork.Payments.FindAsync(p => p.CompanyId == companyId.Value, false, p => p.Invoice, p => p.DepositAccount);
var paymentsCsv = GeneratePaymentsCsv(payments); var paymentsCsv = GeneratePaymentsCsv(payments);
var paymentsEntry = archive.CreateEntry($"payments_{timestamp}.csv"); var paymentsEntry = archive.CreateEntry($"payments_{timestamp}.csv");
using (var entryStream = paymentsEntry.Open()) using (var entryStream = paymentsEntry.Open())
@@ -2083,8 +2230,48 @@ public class ToolsController : Controller
await writer.WriteAsync(paymentsCsv); 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 // 15. Purchase Orders
var purchaseOrders = await _unitOfWork.PurchaseOrders.GetAllAsync(false, po => po.Vendor); var purchaseOrders = await _unitOfWork.PurchaseOrders.FindAsync(po => po.CompanyId == companyId, false, po => po.Vendor);
var purchaseOrdersCsv = GeneratePurchaseOrdersCsv(purchaseOrders); var purchaseOrdersCsv = GeneratePurchaseOrdersCsv(purchaseOrders);
var purchaseOrdersEntry = archive.CreateEntry($"purchase_orders_{timestamp}.csv"); var purchaseOrdersEntry = archive.CreateEntry($"purchase_orders_{timestamp}.csv");
using (var entryStream = purchaseOrdersEntry.Open()) using (var entryStream = purchaseOrdersEntry.Open())
@@ -2147,7 +2334,7 @@ public class ToolsController : Controller
return RedirectToAction(nameof(Index)); return RedirectToAction(nameof(Index));
} }
var customers = await _unitOfWork.Customers.GetAllAsync(false, c => c.PricingTier); var customers = await _unitOfWork.Customers.FindAsync(c => c.CompanyId == companyId, false, c => c.PricingTier);
var csv = GenerateCustomersCsv(customers); var csv = GenerateCustomersCsv(customers);
var fileName = $"customers_export_{DateTime.UtcNow:yyyyMMddHHmmss}.csv"; var fileName = $"customers_export_{DateTime.UtcNow:yyyyMMddHHmmss}.csv";
@@ -2181,7 +2368,7 @@ public class ToolsController : Controller
return RedirectToAction(nameof(Index)); return RedirectToAction(nameof(Index));
} }
var quotes = await _unitOfWork.Quotes.GetAllAsync(false, q => q.Customer, q => q.QuoteStatus); var quotes = await _unitOfWork.Quotes.FindAsync(q => q.CompanyId == companyId, false, q => q.Customer, q => q.QuoteStatus);
var csv = GenerateQuotesCsv(quotes); var csv = GenerateQuotesCsv(quotes);
var fileName = $"quotes_export_{DateTime.UtcNow:yyyyMMddHHmmss}.csv"; var fileName = $"quotes_export_{DateTime.UtcNow:yyyyMMddHHmmss}.csv";
@@ -2214,7 +2401,7 @@ public class ToolsController : Controller
return RedirectToAction(nameof(Index)); return RedirectToAction(nameof(Index));
} }
var jobs = await _unitOfWork.Jobs.GetAllAsync(false, j => j.Customer, j => j.JobStatus, j => j.JobPriority); var jobs = await _unitOfWork.Jobs.FindAsync(j => j.CompanyId == companyId, false, j => j.Customer, j => j.JobStatus, j => j.JobPriority);
var csv = GenerateJobsCsv(jobs); var csv = GenerateJobsCsv(jobs);
var fileName = $"jobs_export_{DateTime.UtcNow:yyyyMMddHHmmss}.csv"; var fileName = $"jobs_export_{DateTime.UtcNow:yyyyMMddHHmmss}.csv";
@@ -2246,7 +2433,7 @@ public class ToolsController : Controller
return RedirectToAction(nameof(Index)); return RedirectToAction(nameof(Index));
} }
var appointments = await _unitOfWork.Appointments.GetAllAsync(false, var appointments = await _unitOfWork.Appointments.FindAsync(a => a.CompanyId == companyId, false,
a => a.Customer, a => a.AppointmentType, a => a.AppointmentStatus); a => a.Customer, a => a.AppointmentType, a => a.AppointmentStatus);
var csv = GenerateAppointmentsCsv(appointments); var csv = GenerateAppointmentsCsv(appointments);
var fileName = $"appointments_export_{DateTime.UtcNow:yyyyMMddHHmmss}.csv"; var fileName = $"appointments_export_{DateTime.UtcNow:yyyyMMddHHmmss}.csv";
@@ -2316,7 +2503,7 @@ public class ToolsController : Controller
return RedirectToAction(nameof(Index)); return RedirectToAction(nameof(Index));
} }
var inventoryItems = await _unitOfWork.InventoryItems.GetAllAsync(false, i => i.PrimaryVendor); var inventoryItems = await _unitOfWork.InventoryItems.FindAsync(i => i.CompanyId == companyId, false, i => i.PrimaryVendor);
var csv = GenerateInventoryCsv(inventoryItems); var csv = GenerateInventoryCsv(inventoryItems);
var fileName = $"inventory_export_{DateTime.UtcNow:yyyyMMddHHmmss}.csv"; var fileName = $"inventory_export_{DateTime.UtcNow:yyyyMMddHHmmss}.csv";
@@ -2383,7 +2570,7 @@ public class ToolsController : Controller
return RedirectToAction(nameof(Index)); return RedirectToAction(nameof(Index));
} }
var maintenance = await _unitOfWork.MaintenanceRecords.GetAllAsync(false, m => m.Equipment); var maintenance = await _unitOfWork.MaintenanceRecords.FindAsync(m => m.CompanyId == companyId, false, m => m.Equipment);
var csv = GenerateMaintenanceCsv(maintenance); var csv = GenerateMaintenanceCsv(maintenance);
var fileName = $"maintenance_export_{DateTime.UtcNow:yyyyMMddHHmmss}.csv"; var fileName = $"maintenance_export_{DateTime.UtcNow:yyyyMMddHHmmss}.csv";
@@ -2471,7 +2658,7 @@ public class ToolsController : Controller
return RedirectToAction(nameof(Index)); return RedirectToAction(nameof(Index));
} }
var invoices = await _unitOfWork.Invoices.GetAllAsync(false, i => i.Customer, i => i.Job); var invoices = await _unitOfWork.Invoices.FindAsync(i => i.CompanyId == companyId, false, i => i.Customer, i => i.Job);
var csv = GenerateInvoicesCsv(invoices); var csv = GenerateInvoicesCsv(invoices);
var fileName = $"invoices_export_{DateTime.UtcNow:yyyyMMddHHmmss}.csv"; var fileName = $"invoices_export_{DateTime.UtcNow:yyyyMMddHHmmss}.csv";
@@ -2504,7 +2691,7 @@ public class ToolsController : Controller
return RedirectToAction(nameof(Index)); return RedirectToAction(nameof(Index));
} }
var payments = await _unitOfWork.Payments.GetAllAsync(false, p => p.Invoice); var payments = await _unitOfWork.Payments.FindAsync(p => p.CompanyId == companyId, false, p => p.Invoice, p => p.DepositAccount);
var csv = GeneratePaymentsCsv(payments); var csv = GeneratePaymentsCsv(payments);
var fileName = $"payments_export_{DateTime.UtcNow:yyyyMMddHHmmss}.csv"; var fileName = $"payments_export_{DateTime.UtcNow:yyyyMMddHHmmss}.csv";
@@ -2519,6 +2706,164 @@ 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> /// <summary>
/// Exports all purchase orders for the current company as a CSV file, including the vendor /// 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. /// company name resolved via eager loading. PO status is written as its enum name.
@@ -2536,7 +2881,7 @@ public class ToolsController : Controller
return RedirectToAction(nameof(Index)); return RedirectToAction(nameof(Index));
} }
var purchaseOrders = await _unitOfWork.PurchaseOrders.GetAllAsync(false, po => po.Vendor); var purchaseOrders = await _unitOfWork.PurchaseOrders.FindAsync(po => po.CompanyId == companyId, false, po => po.Vendor);
var csv = GeneratePurchaseOrdersCsv(purchaseOrders); var csv = GeneratePurchaseOrdersCsv(purchaseOrders);
var fileName = $"purchase_orders_export_{DateTime.UtcNow:yyyyMMddHHmmss}.csv"; var fileName = $"purchase_orders_export_{DateTime.UtcNow:yyyyMMddHHmmss}.csv";
@@ -3973,6 +4318,35 @@ public class ToolsController : Controller
return sb.ToString(); 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> /// <summary>
/// Builds a CSV string for the given invoice payment records. The parent invoice number is /// 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 /// resolved from the eagerly loaded <c>Invoice</c> navigation property. PaymentMethod is
@@ -3981,13 +4355,111 @@ public class ToolsController : Controller
private string GeneratePaymentsCsv(IEnumerable<Core.Entities.Payment> payments) private string GeneratePaymentsCsv(IEnumerable<Core.Entities.Payment> payments)
{ {
var sb = new System.Text.StringBuilder(); var sb = new System.Text.StringBuilder();
sb.AppendLine("InvoiceNumber,Amount,PaymentDate,PaymentMethod,Reference,Notes"); sb.AppendLine("InvoiceNumber,Amount,PaymentDate,PaymentMethod,DepositAccountNumber,Reference,Notes");
foreach (var payment in payments) foreach (var payment in payments)
{ {
sb.AppendLine($"{EscapeCsv(payment.Invoice?.InvoiceNumber)}," + sb.AppendLine($"{EscapeCsv(payment.Invoice?.InvoiceNumber)}," +
$"{payment.Amount},{payment.PaymentDate:yyyy-MM-dd}," + $"{payment.Amount},{payment.PaymentDate:yyyy-MM-dd}," +
$"{payment.PaymentMethod},{EscapeCsv(payment.Reference)},{EscapeCsv(payment.Notes)}"); $"{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}");
}
} }
return sb.ToString(); return sb.ToString();
@@ -4143,7 +4615,7 @@ public class ToolsController : Controller
return RedirectToAction(nameof(Index)); return RedirectToAction(nameof(Index));
} }
var expenses = await _unitOfWork.Expenses.GetAllAsync(false, var expenses = await _unitOfWork.Expenses.FindAsync(e => e.CompanyId == companyId, false,
e => e.ExpenseAccount, e => e.ExpenseAccount,
e => e.PaymentAccount, e => e.PaymentAccount,
e => e.Vendor, e => e.Vendor,
@@ -132,7 +132,7 @@ public class VendorCreditsController : Controller
.Select(l => l.AccountId!.Value) .Select(l => l.AccountId!.Value)
.Distinct() .Distinct()
.ToList(); .ToList();
var accounts = await _unitOfWork.Accounts.FindAsync(a => accountIds.Contains(a.Id)); var accounts = await _unitOfWork.Accounts.FindAsync(a => a.CompanyId == vc.CompanyId && accountIds.Contains(a.Id));
ViewBag.AccountMap = accounts.ToDictionary(a => a.Id, a => $"{a.AccountNumber} {a.Name}"); ViewBag.AccountMap = accounts.ToDictionary(a => a.Id, a => $"{a.AccountNumber} {a.Name}");
// Load bills referenced by applications // Load bills referenced by applications
@@ -357,8 +357,9 @@ public class VendorCreditsController : Controller
private async Task PopulateDropdownsAsync() private async Task PopulateDropdownsAsync()
{ {
var vendors = await _unitOfWork.Vendors.FindAsync(v => v.IsActive); var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var accounts = await _unitOfWork.Accounts.FindAsync(a => a.IsActive); var vendors = await _unitOfWork.Vendors.FindAsync(v => v.CompanyId == companyId && v.IsActive);
var accounts = await _unitOfWork.Accounts.FindAsync(a => a.CompanyId == companyId && a.IsActive);
ViewBag.VendorList = vendors ViewBag.VendorList = vendors
.OrderBy(v => v.CompanyName) .OrderBy(v => v.CompanyName)
@@ -463,8 +463,9 @@ public class VendorsController : Controller
private async Task PopulateExpenseAccountsAsync() private async Task PopulateExpenseAccountsAsync()
{ {
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var accounts = (await _unitOfWork.Accounts.FindAsync( var accounts = (await _unitOfWork.Accounts.FindAsync(
a => a.IsActive && (a.AccountType == AccountType.Expense || a => a.CompanyId == companyId && a.IsActive && (a.AccountType == AccountType.Expense ||
a.AccountType == AccountType.CostOfGoods || a.AccountType == AccountType.CostOfGoods ||
a.AccountType == AccountType.Asset))) a.AccountType == AccountType.Asset)))
.OrderBy(a => a.AccountNumber) .OrderBy(a => a.AccountNumber)
@@ -0,0 +1,28 @@
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,6 +17,27 @@ internal static class AccountingDropdownHelper
/// Returns pre-projected SelectListItem collections so controllers avoid duplicating the /// Returns pre-projected SelectListItem collections so controllers avoid duplicating the
/// LINQ-to-SelectListItem transform. /// LINQ-to-SelectListItem transform.
/// </summary> /// </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) internal static async Task<AccountingDropdowns> LoadAsync(IUnitOfWork unitOfWork)
{ {
var vendors = await unitOfWork.Vendors.FindAsync(v => v.IsActive); var vendors = await unitOfWork.Vendors.FindAsync(v => v.IsActive);
@@ -50,17 +71,21 @@ internal static class AccountingDropdownHelper
.Select(a => new SelectListItem(accountLabel(a), a.Id.ToString())) .Select(a => new SelectListItem(accountLabel(a), a.Id.ToString()))
.ToList(), .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 ApAccounts = allAccounts
.Where(a => a.AccountSubType == AccountSubType.AccountsPayable) .Where(a => a.AccountType == AccountType.Liability)
.OrderBy(a => a.AccountNumber) .OrderBy(a => a.AccountNumber)
.Select(a => new SelectListItem(accountLabel(a), a.Id.ToString())) .Select(a => new SelectListItem(accountLabel(a), a.Id.ToString()))
.ToList(), .ToList(),
// Payment sources span both Assets (cash/checking/savings) and Liabilities
// (credit cards, lines of credit), so include both top-level types.
BankAccounts = allAccounts BankAccounts = allAccounts
.Where(a => a.AccountSubType == AccountSubType.Cash || .Where(a => a.AccountType == AccountType.Asset ||
a.AccountSubType == AccountSubType.Checking || a.AccountType == AccountType.Liability)
a.AccountSubType == AccountSubType.Savings ||
a.AccountSubType == AccountSubType.CreditCard)
.OrderBy(a => a.AccountNumber) .OrderBy(a => a.AccountNumber)
.Select(a => new SelectListItem(accountLabel(a), a.Id.ToString())) .Select(a => new SelectListItem(accountLabel(a), a.Id.ToString()))
.ToList(), .ToList(),
@@ -481,6 +481,8 @@ public static class HelpKnowledgeBase
5. Enter opening quantity on hand the system automatically records an Initial transaction for audit purposes 5. Enter opening quantity on hand the system automatically records an Initial transaction for audit purposes
6. Save 6. Save
**Manufacturer catalog integration:** The platform is integrated with the Columbia Coatings product catalog. When you add a Columbia powder it auto-fills the color, specs, cure schedule, and SDS/TDS links, and keeps the price current catalog data refreshes regularly (near real-time). Quotes use the latest catalog price even when your stored cost is older, and a powder's detail page shows the current catalog price (and flags when it has changed since you last bought it). Discontinued powders are flagged "cannot reorder" but stay usable for stock you already have.
**Stock status:** Three states are shown on every item: **Stock status:** Three states are shown on every item:
- **In Stock** (green) quantity is above the reorder point - **In Stock** (green) quantity is above the reorder point
- **Low Stock** (red) quantity is greater than zero but at or below the reorder point; time to reorder - **Low Stock** (red) quantity is greater than zero but at or below the reorder point; time to reorder
@@ -676,6 +678,12 @@ public static class HelpKnowledgeBase
**Step 5 Set up your Chart of Accounts (for billing/AP)** **Step 5 Set up your Chart of Accounts (for billing/AP)**
If you use the Bills and accounting features, go to [Chart of Accounts](/Accounts) and confirm the seeded accounts fit your setup. The wizard seeds a standard set automatically. If you use the Bills and accounting features, go to [Chart of Accounts](/Accounts) and confirm the seeded accounts fit your setup. The wizard seeds a standard set automatically.
**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?** **What happens if Operating Costs are zero?**
If you skip the pricing setup steps, every quote will calculate $0 (or only the tax amount). The Dashboard "Setup Incomplete" card will show red badges pointing to exactly what's missing and link directly to the fix. If you skip the pricing setup steps, every quote will calculate $0 (or only the tax amount). The Dashboard "Setup Incomplete" card will show red badges pointing to exactly what's missing and link directly to the fix.
+5
View File
@@ -11,6 +11,7 @@ using PowderCoating.Core.Interfaces.Services;
using PowderCoating.Infrastructure.Data; using PowderCoating.Infrastructure.Data;
using PowderCoating.Infrastructure.Repositories; using PowderCoating.Infrastructure.Repositories;
using PowderCoating.Infrastructure.Services; using PowderCoating.Infrastructure.Services;
using PowderCoating.Infrastructure.Services.Columbia;
using PowderCoating.Application.Interfaces; using PowderCoating.Application.Interfaces;
using PowderCoating.Application.Services; using PowderCoating.Application.Services;
using PowderCoating.Application.Configuration; using PowderCoating.Application.Configuration;
@@ -222,6 +223,9 @@ builder.Services.AddScoped<IInventoryAiLookupService, InventoryAiLookupService>(
builder.Services.AddScoped<ICustomFormulaAiService, CustomFormulaAiService>(); builder.Services.AddScoped<ICustomFormulaAiService, CustomFormulaAiService>();
builder.Services.AddScoped<IFormulaLibraryService, FormulaLibraryService>(); builder.Services.AddScoped<IFormulaLibraryService, FormulaLibraryService>();
builder.Services.AddHttpClient(); builder.Services.AddHttpClient();
builder.Services.AddScoped<IColumbiaCoatingsApiClient, ColumbiaCoatingsApiClient>();
builder.Services.AddScoped<IPowderCatalogUpsertService, PowderCatalogUpsertService>();
builder.Services.AddScoped<IColumbiaCatalogSyncService, ColumbiaCatalogSyncService>();
builder.Services.AddScoped<ICompanyLogoService, CompanyLogoService>(); builder.Services.AddScoped<ICompanyLogoService, CompanyLogoService>();
builder.Services.AddScoped<IEquipmentManualService, EquipmentManualService>(); builder.Services.AddScoped<IEquipmentManualService, EquipmentManualService>();
builder.Services.AddScoped<IPricingCalculationService, PricingCalculationService>(); builder.Services.AddScoped<IPricingCalculationService, PricingCalculationService>();
@@ -255,6 +259,7 @@ builder.Services.AddHostedService<StripeWebhookRetentionBackgroundService>();
builder.Services.AddHostedService<SetupWizardReminderBackgroundService>(); builder.Services.AddHostedService<SetupWizardReminderBackgroundService>();
builder.Services.AddHostedService<RecurringTransactionService>(); builder.Services.AddHostedService<RecurringTransactionService>();
builder.Services.AddHostedService<AppointmentReminderBackgroundService>(); builder.Services.AddHostedService<AppointmentReminderBackgroundService>();
builder.Services.AddHostedService<ColumbiaCatalogSyncBackgroundService>();
builder.Services.AddScoped<ISubscriptionService, SubscriptionService>(); builder.Services.AddScoped<ISubscriptionService, SubscriptionService>();
builder.Services.AddScoped<IStripeService, StripeService>(); builder.Services.AddScoped<IStripeService, StripeService>();
builder.Services.AddScoped<IStripeConnectService, StripeConnectService>(); builder.Services.AddScoped<IStripeConnectService, StripeConnectService>();
@@ -565,7 +565,14 @@ public class QuickBooksOnlineService
var parentName = name.Contains(':') ? name.Substring(0, name.LastIndexOf(':')).Trim() : null; var parentName = name.Contains(':') ? name.Substring(0, name.LastIndexOf(':')).Trim() : null;
result.TotalRecords++; result.TotalRecords++;
rows.Add((displayName, parentName, number, desc, MapQboAccountType(typeStr), MapQboDetailType(detailType))); // QBO Type (high-level) is reliable; DetailType often isn't mappable and falls back to
// Other. Reconcile so sub-type's parent always matches the type — otherwise an unmapped
// liability/equity/revenue would get an expense-range sub-type and post with the wrong sign.
var acctType = MapQboAccountType(typeStr);
var subType = MapQboDetailType(detailType);
if (AccountClassification.TypeForSubType(subType) != acctType)
subType = AccountClassification.DefaultSubTypeForType(acctType);
rows.Add((displayName, parentName, number, desc, acctType, subType));
} }
// Pass 1: upsert every account WITHOUT parent links so they all get IDs. // Pass 1: upsert every account WITHOUT parent links so they all get IDs.
@@ -36,16 +36,34 @@
}; };
} }
<div class="d-flex justify-content-end mb-4"> @{
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 gap-2"> <div class="d-flex gap-2">
<form asp-action="FixOpeningBalanceSigns" method="post"
onsubmit="return confirm('Fix opening balance signs for Revenue, Liability, and Equity accounts imported from QuickBooks? This corrects negative balances caused by QB\'s sign convention.')">
@Html.AntiForgeryToken()
<button type="submit" class="btn btn-outline-warning"
title="Fixes negative opening balances on Revenue/Liability/Equity accounts imported from QuickBooks IIF">
<i class="bi bi-sign-stop me-1"></i>Fix QB Import Signs
</button>
</form>
<form id="recalcBalancesForm" asp-action="RecalculateBalances" method="post"> <form id="recalcBalancesForm" asp-action="RecalculateBalances" method="post">
@Html.AntiForgeryToken() @Html.AntiForgeryToken()
<button type="button" id="btnRecalcBalances" class="btn btn-outline-secondary" <button type="button" id="btnRecalcBalances" class="btn btn-outline-secondary"
@@ -93,6 +111,80 @@
</div> </div>
} }
@if (Model.Any())
{
var revenueAccts = ViewBag.DefaultRevenueAccounts as List<Microsoft.AspNetCore.Mvc.Rendering.SelectListItem> ?? new();
var cogsAccts = ViewBag.DefaultCogsAccounts as List<Microsoft.AspNetCore.Mvc.Rendering.SelectListItem> ?? new();
var inventoryAccts = ViewBag.DefaultInventoryAccounts as List<Microsoft.AspNetCore.Mvc.Rendering.SelectListItem> ?? new();
int? selRevenue = ViewBag.DefaultRevenueAccountId as int?;
int? selCogs = ViewBag.DefaultCogsAccountId as int?;
int? selInventory = ViewBag.DefaultInventoryAccountId as int?;
<div class="card shadow-sm mb-4">
<div class="card-header bg-light d-flex justify-content-between align-items-center">
<h6 class="card-title mb-0"><i class="bi bi-gear me-2 text-primary"></i>Default Accounts</h6>
<button class="btn btn-sm btn-outline-secondary" type="button" data-bs-toggle="collapse" data-bs-target="#defaultAccountsBody">
<i class="bi bi-pencil me-1"></i>Set Defaults
</button>
</div>
<div id="defaultAccountsBody" class="collapse">
<div class="card-body">
<p class="text-muted small mb-3">
These accounts are used automatically when an item or invoice line doesn&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()) @if (!Model.Any())
{ {
<div class="card shadow-sm border-0"> <div class="card shadow-sm border-0">
@@ -131,7 +131,7 @@
<div class="col-md-6 mb-3"> <div class="col-md-6 mb-3">
<label asp-for="RevenueAccountId" class="form-label"></label> <label asp-for="RevenueAccountId" class="form-label"></label>
<select asp-for="RevenueAccountId" class="form-select" asp-items="ViewBag.RevenueAccounts"> <select asp-for="RevenueAccountId" class="form-select" asp-items="ViewBag.RevenueAccounts">
<option value="">(Default revenue account)</option> <option value="">@((ViewBag.HasDefaultRevenueAccount ?? false) ? "(Default revenue account)" : "(None)")</option>
</select> </select>
<small class="form-text text-muted">Account credited when this item is invoiced.</small> <small class="form-text text-muted">Account credited when this item is invoiced.</small>
</div> </div>
@@ -139,7 +139,7 @@
<label asp-for="CogsAccountId" class="form-label"></label> <label asp-for="CogsAccountId" class="form-label"></label>
<select asp-for="CogsAccountId" class="form-select" asp-items="ViewBag.CogsAccounts" <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"> data-quick-add-url="/Accounts/Create?preSubType=40" data-quick-add-title="Add COGS Account">
<option value="">(Default COGS account)</option> <option value="">@((ViewBag.HasDefaultCogsAccount ?? false) ? "(Default COGS account)" : "(None)")</option>
<option value="__new__">+ Add New Account&hellip;</option> <option value="__new__">+ Add New Account&hellip;</option>
</select> </select>
<small class="form-text text-muted">Account debited when materials are consumed.</small> <small class="form-text text-muted">Account debited when materials are consumed.</small>
@@ -134,7 +134,7 @@
<div class="col-md-6 mb-3"> <div class="col-md-6 mb-3">
<label asp-for="RevenueAccountId" class="form-label"></label> <label asp-for="RevenueAccountId" class="form-label"></label>
<select asp-for="RevenueAccountId" class="form-select" asp-items="ViewBag.RevenueAccounts"> <select asp-for="RevenueAccountId" class="form-select" asp-items="ViewBag.RevenueAccounts">
<option value="">(Default revenue account)</option> <option value="">@((ViewBag.HasDefaultRevenueAccount ?? false) ? "(Default revenue account)" : "(None)")</option>
</select> </select>
<small class="form-text text-muted">Account credited when this item is invoiced.</small> <small class="form-text text-muted">Account credited when this item is invoiced.</small>
</div> </div>
@@ -142,7 +142,7 @@
<label asp-for="CogsAccountId" class="form-label"></label> <label asp-for="CogsAccountId" class="form-label"></label>
<select asp-for="CogsAccountId" class="form-select" asp-items="ViewBag.CogsAccounts" <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"> data-quick-add-url="/Accounts/Create?preSubType=40" data-quick-add-title="Add COGS Account">
<option value="">(Default COGS account)</option> <option value="">@((ViewBag.HasDefaultCogsAccount ?? false) ? "(Default COGS account)" : "(None)")</option>
<option value="__new__">+ Add New Account&hellip;</option> <option value="__new__">+ Add New Account&hellip;</option>
</select> </select>
<small class="form-text text-muted">Account debited when materials are consumed.</small> <small class="form-text text-muted">Account debited when materials are consumed.</small>
@@ -618,6 +618,36 @@
</div><!-- /tab-content --> </div><!-- /tab-content -->
<!-- Maintenance Tools (SuperAdmin platform utilities) -->
<div class="card shadow-sm mt-4">
<div class="card-header bg-light">
<h6 class="card-title mb-0">
<i class="bi bi-tools me-2"></i>Maintenance Tools
</h6>
</div>
<div class="card-body d-flex flex-column gap-3">
<div class="d-flex justify-content-between align-items-start border rounded p-3">
<div>
<h6 class="mb-1"><i class="bi bi-sign-stop me-1"></i>Fix QuickBooks Import Signs</h6>
<p class="text-muted small mb-0">
Flips negative opening balances on Revenue, Liability, and Equity accounts to positive.
QuickBooks IIF exports store these credit-normal accounts as negative numbers; this
corrects them so the Chart of Accounts reads correctly. Run <strong>Recalculate Balances</strong>
on the company's Chart of Accounts afterward. Safe to run more than once.
</p>
</div>
<form asp-action="FixOpeningBalanceSigns" asp-route-id="@Model.Id" method="post"
onsubmit="return confirm('Fix opening balance signs for Revenue, Liability, and Equity accounts imported from QuickBooks for this company?')">
@Html.AntiForgeryToken()
<button type="submit" class="btn btn-outline-warning ms-3 text-nowrap"
title="Fixes negative opening balances on Revenue/Liability/Equity accounts imported from QuickBooks IIF">
<i class="bi bi-sign-stop me-1"></i>Fix QB Import Signs
</button>
</form>
</div>
</div>
</div>
<!-- Danger Zone (outside tabs &mdash; always present) --> <!-- Danger Zone (outside tabs &mdash; always present) -->
<div class="card shadow-sm border-danger mt-4"> <div class="card shadow-sm border-danger mt-4">
<div class="card-header bg-light"> <div class="card-header bg-light">
@@ -1041,6 +1041,37 @@
// Custom powder (no inventory item) â†' open modal to add to inventory // Custom powder (no inventory item) â†' open modal to add to inventory
if (!hasInv) { if (!hasInv) {
// If the powder is already in the master catalog, receive it straight to inventory
// with all its specs/docs — no modal. Only fall back to the modal when it isn't.
const tokenAuto = document.querySelector('input[name="__RequestVerificationToken"]')?.value
?? document.querySelector('meta[name="__RequestVerificationToken"]')?.content;
this.disabled = true; qtyInput.disabled = true;
this.innerHTML = '<span class="spinner-border spinner-border-sm"></span>';
try {
const autoResp = await fetch('@Url.Action("ReceivePowderFromCatalog", "Dashboard")', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'RequestVerificationToken': tokenAuto },
body: `coatId=${coatId}&lbsReceived=${lbs}`
});
const autoData = await autoResp.json();
if (autoData.success) {
fadePlacedRow(row);
showInventoryToast('Added "' + (autoData.itemName || 'powder') + '" to inventory from the catalog.');
return;
}
if (!autoData.needsDetails) {
alert(autoData.message || 'Could not record receipt. Please try again.');
this.disabled = false; qtyInput.disabled = false;
this.innerHTML = '<i class="bi bi-box-arrow-in-down"></i> Got It';
return;
}
// Not in catalog — fall through to the manual entry modal.
} catch {
// Network error — fall back to the manual entry modal.
}
this.disabled = false; qtyInput.disabled = false;
this.innerHTML = '<i class="bi bi-box-arrow-in-down"></i> Got It';
const modal = document.getElementById('addPowderModal'); const modal = document.getElementById('addPowderModal');
// Pre-fill hidden + text fields // Pre-fill hidden + text fields
modal.querySelector('#apm-coatId').value = coatId; modal.querySelector('#apm-coatId').value = coatId;
@@ -138,6 +138,18 @@
<li class="mb-1">If a vendor name is selected in the Vendor field before searching, results are scoped to that vendor first, then broadened automatically if nothing matches.</li> <li class="mb-1">If a vendor name is selected in the Vendor field before searching, results are scoped to that vendor first, then broadened automatically if nothing matches.</li>
</ul> </ul>
<div class="alert alert-permanent alert-success d-flex gap-2 mb-3" role="alert">
<i class="bi bi-cloud-check me-1 flex-shrink-0 mt-1"></i>
<div>
<strong>Columbia Coatings integration:</strong> the catalog is connected directly to the Columbia Coatings
product catalog and refreshes regularly (near real-time). Columbia powders auto-fill their full specs, cure
schedule, and SDS/TDS links, and their prices stay current &mdash; quotes use the latest catalog price even
when your stored cost is older. An item&rsquo;s detail page shows the current catalog price and flags when it
has changed since you last bought it. Discontinued powders are flagged &ldquo;cannot reorder&rdquo; but stay
usable for stock you already have.
</div>
</div>
<h3 class="h6 fw-semibold mt-4 mb-2"><i class="bi bi-camera me-1"></i>Label Scanner (Camera)</h3> <h3 class="h6 fw-semibold mt-4 mb-2"><i class="bi bi-camera me-1"></i>Label Scanner (Camera)</h3>
<p> <p>
Click the <strong>camera icon</strong> next to the Lookup button to open the label scanner. Click the <strong>camera icon</strong> next to the Lookup button to open the label scanner.
@@ -237,6 +237,40 @@
The chart of accounts is typically configured once during initial setup. You can add new accounts The chart of accounts is typically configured once during initial setup. You can add new accounts
at any time if your accounting needs expand. at any time if your accounting needs expand.
</p> </p>
<h3 class="h6 fw-semibold mt-3 mb-2">Default Accounts</h3>
<p>
The <strong>Default Accounts</strong> card at the top of the Chart of Accounts page lets you choose a
default <strong>Revenue</strong>, <strong>COGS</strong>, and <strong>Inventory Asset</strong> account
for your company. These are used automatically when an item or invoice line doesn&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"> <div class="alert alert-permanent alert-warning d-flex gap-2 mb-0" role="alert">
<i class="bi bi-exclamation-triangle-fill flex-shrink-0 mt-1"></i> <i class="bi bi-exclamation-triangle-fill flex-shrink-0 mt-1"></i>
<div> <div>
@@ -17,8 +17,10 @@
<div class="card border-0 shadow-sm"> <div class="card border-0 shadow-sm">
<div class="card-body p-4"> <div class="card-body p-4">
<form asp-action="Create" method="post"> <form asp-action="Create" method="post" id="inventory-create-form">
<input type="hidden" asp-for="DuplicateOverrideInventoryItemId" id="duplicate-override-id" />
<partial name="_ValidationSummary" /> <partial name="_ValidationSummary" />
<div id="inventory-duplicate-status" class="d-none mb-3" role="alert"></div>
<!-- Basic Information --> <!-- Basic Information -->
<div class="mb-4"> <div class="mb-4">
@@ -373,7 +375,7 @@
<label asp-for="InventoryAccountId" class="form-label"></label> <label asp-for="InventoryAccountId" class="form-label"></label>
<select asp-for="InventoryAccountId" class="form-select" asp-items="ViewBag.InventoryAccounts" <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"> data-quick-add-url="/Accounts/Create?preSubType=4" data-quick-add-title="Add Inventory Account">
<option value="">(Default inventory account)</option> <option value="">@((ViewBag.HasDefaultInventoryAccount ?? false) ? "(Default inventory account)" : "(None)")</option>
<option value="__new__">+ Add New Account&hellip;</option> <option value="__new__">+ Add New Account&hellip;</option>
</select> </select>
<small class="form-text text-muted">Asset account where inventory value is tracked (e.g., 1200 Inventory - Powder).</small> <small class="form-text text-muted">Asset account where inventory value is tracked (e.g., 1200 Inventory - Powder).</small>
@@ -382,7 +384,7 @@
<label asp-for="CogsAccountId" class="form-label"></label> <label asp-for="CogsAccountId" class="form-label"></label>
<select asp-for="CogsAccountId" class="form-select" asp-items="ViewBag.CogsAccounts" <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"> data-quick-add-url="/Accounts/Create?preSubType=40" data-quick-add-title="Add COGS Account">
<option value="">(Default COGS account)</option> <option value="">@((ViewBag.HasDefaultCogsAccount ?? false) ? "(Default COGS account)" : "(None)")</option>
<option value="__new__">+ Add New Account&hellip;</option> <option value="__new__">+ Add New Account&hellip;</option>
</select> </select>
<small class="form-text text-muted">Expense account debited when this material is consumed on a job.</small> <small class="form-text text-muted">Expense account debited when this material is consumed on a job.</small>
@@ -428,16 +430,15 @@
</div> </div>
</div> </div>
@if ((bool)(ViewBag.AiInventoryAssistEnabled ?? false))
{
<partial name="_LabelScanModal" /> <partial name="_LabelScanModal" />
}
@section Scripts { @section Scripts {
<partial name="_ValidationScriptsPartial" /> <partial name="_ValidationScriptsPartial" />
<script>const inventoryFormIsCreate = true;</script> <script>const inventoryFormIsCreate = true;</script>
<script src="~/js/inventory-vendor-match.js"></script>
<partial name="_InventoryColorFamilyScripts" /> <partial name="_InventoryColorFamilyScripts" />
<script src="~/js/inventory-catalog-lookup.js"></script> <script src="~/js/inventory-catalog-lookup.js"></script>
<script src="~/js/inventory-duplicate-check.js"></script>
@if ((bool)(ViewBag.AiInventoryAssistEnabled ?? false)) @if ((bool)(ViewBag.AiInventoryAssistEnabled ?? false))
{ {
<script src="~/js/inventory-label-scan.js"></script> <script src="~/js/inventory-label-scan.js"></script>

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