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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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 "—" 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>
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>
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>
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>
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>
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>
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>
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>
The wizard scroll-restore saved scroll position on form submit but never
cleared it if the server redirected to a success page. Next fresh visit
to the same URL found the stale sessionStorage key and jumped down.
Fix: track whether the page unload was caused by our own form submit.
On pagehide for any other reason (nav link, success redirect), remove
the key so it never fires on a clean page load.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Wrap Line Items and Payment History tables in table-responsive so they
scroll horizontally rather than overflowing the viewport. Expenses detail
page uses a definition list layout and was not affected.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Wraps the desktop table in table-responsive to fix horizontal scrolling,
and adds a mobile-card-view section matching the pattern used on Invoices,
PurchaseOrders, and other list pages. Cards show type, number, vendor,
status, date, due date, amount, balance due, and memo/account.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds SuppressNotification to RecordPaymentDto and a checkbox to the
modal. When checked, the payment is fully recorded but NotifyPaymentReceivedAsync
is skipped — useful for historical imports or cases where the customer
should not receive an email.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Bare /Invoices now redirects to statusGroup=unpaid (Draft, Sent, Overdue)
so the list is immediately actionable. Four pills — All, Unpaid, Partial,
Paid — mirror the Jobs page pattern with live badge counts. The existing
status dropdown and outstanding/thisMonth flags are preserved for
dashboard deep-links.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
textContent treats — as a plain string; replaced with innerHTML
for static dash placeholders, and — JS escape where user input
is concatenated. Also removed a dead textContent line in timeclock-kiosk.js
that was immediately overwritten by innerHTML on the next line.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The Quotes Index stat strip (OPEN / APPROVED / TOTAL VALUE) summed every
non-deleted quote, while the default list hides Converted quotes. A quote
converted to a job (whose deletion is blocked by the linked job) therefore
stayed invisible in the list but kept inflating the cards -- e.g. a blank
list showing "1" and a non-zero total value.
GetIndexStatsAsync now excludes the Converted status so the cards reflect
the same population as the default list. Converted value is intentionally
dropped from the quote pipeline because it carries forward on the job
(counting it in both would double-count the same dollars).
Also adds an explicit CompanyId predicate to GetIndexStatsAsync (defense in
depth) -- it was the only Quote query in the typed repo relying solely on
the global tenant filter.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Save button was type=submit, so HTML5 form validation silently blocked
the submit event and nothing happened on click. Switch to type=button
with an explicit click handler. Also replace AutoMapper Map() with
explicit property assignment so EF reliably detects the mutations, and
re-enable the button in showButtonSuccess() after a successful save.
Cherry-picked CompanySettings hunks from dev commit 0b839d0746 as a
targeted production patch off v2026.06.09.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
ReleaseNotesController had [Authorize] only on Index(), leaving the class
unprotected at declaration level — any future unannotated action would be
publicly accessible.
KioskController had no class-level auth, meaning PushSmsConsent() and
CancelSmsConsent() (staff-only POST actions) were reachable by anonymous
callers. [AllowAnonymous] on the existing tablet/intake actions still
overrides correctly, so the customer-facing kiosk flow is unaffected.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
When multiple jobs need the same powder, the 'Powder in Queue to be
Ordered' panel now collapses them into a single line (summed lbs) rather
than showing one row per coat. 'Mark as Ordered' marks all contributing
coats at once and injects each into the 'Awaiting Receipt' panel
individually so per-coat receiving still works unchanged.
- Add PowderOrderJobRefDto; PowderOrderLineDto gains CoatIds + Jobs lists
(scalar CoatId/JobId/etc. become computed accessors for backward compat)
- MapPowderOrderGroupsMerged: secondary GroupBy on (ColorName, ColorCode,
Finish, SKU) within vendor group for the 'needed' panel
- MapPowderOrderGroups kept per-coat for the 'awaiting receipt' panel
- MarkPowderOrdered accepts comma-separated coatIds, returns coats array
- Dashboard view: Customer column loops job refs for merged rows; JS posts
coatIds and iterates data.coats to populate awaiting-receipt panel
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Catalog, calculated, generic, formula, and AI item types now accept
decimal quantities (e.g. 0.25 for a quarter of a catalog set). Sales/
merchandise items remain whole-number only.
- Input min changed from 1 to 0.01; step="0.01" added where missing
- All parseInt reads on quantity inputs changed to parseFloat so values
like 0.25 aren't truncated to 0 before being stored in wz.data
- Server-side Quantity is already decimal on all relevant DTOs
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>