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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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.
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>
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>
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>
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>
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>
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>
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>
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>
Deletes the committed dotnet publish output folder (434 files: DLLs,
bundled static assets) plus 73 stray root files (old *_FIX/*_SUMMARY
docs, .bak files, loose .sql scripts, deploy.zip, screenshots) and a
few scripts/. Repo housekeeping to reclaim disk space; no src/ or
wwwroot/ files touched.
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>
GHSA-37gx-xxp4-5rgx and GHSA-w3x6-4m5h-cxqf (XML signature vulns) affect
8.0.2 transitively. No patched version exists in the NuGet feed yet — 9.0.0
is also flagged. Tracked in Directory.Build.props for re-check when a fix ships.
System.Net.Http 4.1.0 and System.Security.Cryptography.X509Certificates 4.1.0
are false positives: same NCalc2 -> Antlr4 -> NETStandard.Library 1.6.0 chain
already documented; .NET 8 BCL provides the runtime versions.
Microsoft.Build / NuGet.* are build-tooling-only, not deployed to production.
Co-Authored-By: Claude Sonnet 4.6 <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>