Compare commits

...

130 Commits

Author SHA1 Message Date
spouliot 31c5746e5b Guard ShopWorker drops in AddAppointmentReminderSentAt migration with IF EXISTS
Prod and dev databases diverged on whether ShopWorker tables and indexes
exist, causing unconditional DROP statements to fail on prod. Replaced
all individual DropForeignKey/DropTable/DropIndex/DropColumn calls with
a single SQL block using IF EXISTS guards so the migration runs safely
regardless of DB state.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 17:43:30 -04:00
spouliot 3f9ac27afa Merge dev into master for release v2026.05.19 2026-05-19 16:37:26 -04:00
spouliot df504674e9 Add oven/batch settings to job create and edit forms
CreateJobDto and UpdateJobDto now carry OvenCostId, OvenBatches, and
OvenCycleMinutes. The Create POST sets these on the new Job entity and
passes them to the pricing engine; the Edit GET populates them from the
existing job so the form reflects saved values, and the Edit POST writes
them back before repricing.

Both Jobs/Create.cshtml and Jobs/Edit.cshtml now include an Oven & Batch
Settings card (matching the quote form) with oven selector, batch count,
and cycle time inputs. The wizard init block now passes the selected
OvenCostId instead of null so live auto-pricing reflects the oven cost.

ViewBag.DefaultOvenCycleMinutes added to PopulateCreateEditWizardViewBagsAsync
so the placeholder in both views shows the company default.

Also fixed: NoExtraLayerCharge was missing from the Edit GET coat DTO
mapping (would have caused the flag to reset to false on next edit).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 16:27:54 -04:00
spouliot 07796b05c8 Clear ReminderSentAt when appointment is rescheduled
Edit POST now detects if ScheduledStartTime changed (via previousStart
comparison after AutoMapper merge) and nulls ReminderSentAt so the
background service will fire the reminder again at the new time.
Calendar drag-drop (UpdateEventTime) always clears ReminderSentAt since
rescheduling is its only purpose.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 16:03:58 -04:00
spouliot 2bf8871892 Fix NoExtraLayerCharge persistence, appointment reminders, coat notes display, scroll restoration, and invoice Send dead-button
- Appointment reminders: add AppointmentReminderBackgroundService (60s poll), ReminderSentAt
  dedup stamp, NotifyAppointmentReminderAsync sends both customer email and creator staff email;
  AppointmentReminderStaff notification type + default template added; DateTime.Now used instead
  of UtcNow to match locally-stored ScheduledStartTime; ToLocalTime() double-conversion removed

- NoExtraLayerCharge not persisted: flag existed on CreateQuoteItemCoatDto and was used by
  pricing engine but never written to JobItemCoat/QuoteItemCoat entities — every edit reset it
  to false and re-applied the extra layer charge; added column to both entities (migration
  AddNoExtraLayerChargeToCoats), both read DTOs, all 3 JobItemAssemblyService overloads,
  JobItemCoatSeed inner class, and existingItemsData JSON in all 5 wizard views; fixed JS
  template path that hard-coded noExtraLayerCharge: false

- Coat notes not visible: notes were rendered in desktop job details but missing from the wizard
  item card summary and the mobile card view; both fixed

- Scroll position lost on item save: sessionStorage save/restore added to item-wizard.js owner
  form submit handler; path-keyed so cross-page navigation does not restore stale position;
  requestAnimationFrame used for reliable mobile scroll restoration

- Invoice Send dead button: #sendChannelModal was gated inside @if (isDraft) but the button
  targeting it fires for Sent/Overdue invoices too when customer has both email and SMS; modal
  moved outside the Draft guard

- InitialCreate migration added for fresh database installs; Baseline migration guarded with
  IF OBJECT_ID check so it no-ops on fresh DBs; Razor scoping bug fixed in Customers/Index.cshtml

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 15:48:16 -04:00
spouliot 8a0a564885 Merge dev into master for release v2026.05.18 2026-05-18 19:08:11 -04:00
spouliot dd4785b048 Fix empty-state button/text on list pages when search returns no results
Show 'Add Your First X' and onboarding copy only when the list is truly
empty. When a search or filter is active with no results, show 'Add X'
and 'No X match your search/filters' instead.

Affected: Customers (table + mobile views), Equipment, Inventory.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 18:49:23 -04:00
jenkins e185e3b7e3 Add XML doc comments to pricing assembly services
Added comprehensive XML documentation to JobItemAssemblyService and
QuotePricingAssemblyService — the most complex area of the codebase.
Comments explain the three-overload pattern, seed class rationale,
powder-to-order formula and industry default fallbacks, AI prediction
override tracking, and the incoming inventory auto-creation workflow.
PricingCalculationService was already well-documented; no changes needed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 14:22:32 -04:00
spouliot 8acbc8605d Harden multi-tenant isolation across all user-facing controllers
Added explicit CompanyId == companyId predicates to every tenant-scoped
query in 22 controllers so cross-tenant data leakage is impossible even
if EF Core global query filters are bypassed or misconfigured.

Also fixed ApplicationDbContext.IsPlatformAdmin to correctly return true
for SuperAdmins with no CompanyId claim (break-glass accounts) and when
no HTTP context is present (background services, unit tests), resolving
225 unit test failures that stemmed from the global filter blocking all
in-memory test data.

New MultiTenantIsolationTests class (8 tests) verifies the explicit
predicate layer independently of the global query filters.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-17 18:04:22 -04:00
spouliot 485f0b69c8 Format Log Material dropdown as 'Manufacturer - Name (UoM)'
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 21:51:32 -04:00
spouliot f380c152ca Promote job powders to top of Log Material dropdown
Powders already assigned to this job's coats appear under a 'This Job'
section header, then a divider, then 'All Inventory' — so the most
relevant choices are always one click away.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 21:49:46 -04:00
spouliot 79c8c7e6a4 Add manufacturer to Log Material item combobox
Shows manufacturer name as muted secondary text in each dropdown row
and includes it in the search filter, so users can find a powder by
brand when multiple items share a similar name.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 21:46:48 -04:00
spouliot 6cf355071b Replace Log Material item dropdown with searchable combobox
Inventory lists grow over time; a plain <select> becomes unusable. The
new combobox filters as you type, supports keyboard navigation
(Arrow/Enter/Escape), and shows current stock on selection — matching
the pattern used by the powder picker in the item wizard.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 21:41:14 -04:00
spouliot ebd474ae81 Fix log material dropdown showing undefined - camelCase JSON serialization
System.Text.Json defaults to PascalCase; JS reads camelCase. Add
JsonNamingPolicy.CamelCase to the InventoryItemsForModal serialization.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 21:15:23 -04:00
spouliot 3c390a2e05 Merge branch 'dev' - invoice fixes, log material modal, complete job UX 2026-05-16 15:38:05 -04:00
spouliot 0df2353d4f Complete Job modal: ask powder usage once per color, not per item/coat
The modal was showing one row per coat per item, so a job with 5 items
each with 2 coats of the same powder produced 10 identical input rows.

Now groups by unique InventoryItemId and shows one row per powder color
for the whole job. The controller distributes the entered total across
coats proportionally by their estimated PowderToOrder so per-coat
reporting data is preserved. A single inventory transaction is created
per powder (net of any pre-logged scan credit).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 12:30:30 -04:00
spouliot be0a5b26e2 Update AI assistant and help docs for invoice and material logging changes
- HelpKnowledgeBase: invoice-from-job now mentions discount carried over,
  Discount Applied display row, and negative line items; new entry for
  PC-based Log Material modal on job details
- Help/Invoices.cshtml: from-job steps updated with discount/terms/due date
  pre-fill detail; sending section corrects due date source (quote/customer)
- Help/Jobs.cshtml: new "Logging Material Usage from a PC" section documenting
  the Log Material modal alongside the existing QR scan instructions

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 12:15:20 -04:00
spouliot 36680eced9 Add manual Log Material modal to job details page
PC users were blocked to QR scan only for logging material usage. Now a
"Log Material" button opens an inline modal with:
- Inventory item dropdown (name + unit of measure, current stock shown on select)
- Entry method toggle: "Amount Used" or "Amount Remaining" (computes used = onHand - remaining)
- Reason: Job Usage or Waste/Spillage
- Notes field
Submits via AJAX to Jobs/LogMaterial (new POST action) which mirrors the
InventoryController.LogUsage flow — updates QuantityOnHand, creates InventoryTransaction,
posts GL entries (DR COGS / CR Inventory). QR scan button retained as icon.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 12:10:54 -04:00
spouliot 27aa4e0ea6 Invoice create: show discount row in totals, allow negative line items
- Add "Discount Applied" display row (red, hidden when zero) between subtotal
  and tax so users can see the discount being deducted at a glance
- Remove min="0" from UnitPrice and TotalPrice inputs (server-rendered and JS
  template) so negative adjustment lines can be entered without form rejection

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 11:41:47 -04:00
spouliot b2d6fae400 Fix failing test: revert quote-based discount to use sourceQuote.DiscountAmount
The quote discount must come from the agreed quote price, not the job's pricing
snapshot (which may have DiscountAmount=0 for legacy or unset reasons). The job
snapshot fix only applies to direct jobs where no source quote exists.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 11:29:12 -04:00
spouliot 3a1928f9bf Fix invoice creation from job: discount ignored, wrong due date, wrong terms
- DueDate was computed from DefaultTurnaroundDays (a shop ops setting) instead
  of from the payment terms string; now uses PaymentTermsParser throughout
- Discount was never applied for direct jobs (PricingBreakdownJson was read for
  fees but DiscountAmount was silently skipped)
- Quote-based jobs used sourceQuote.DiscountAmount, ignoring any discount edits
  made to the job after quote conversion; now prefers the job's pricing snapshot
- Payment terms and due date now inherit from sourceQuote.Terms → customer.PaymentTerms
  → company default, so the invoice reflects the agreed or customer-specific terms
- EarlyPaymentDiscount fields now populated from inherited terms

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 10:45:40 -04:00
spouliot df9863a0bb Merge branch 'dev' 2026-05-15 21:13:04 -04:00
spouliot 6cefdff18c Ignore TODO.txt from source control
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 21:06:48 -04:00
spouliot 91a5dbe30c Reorganize Operating Costs tab into individual section cards
Replaces single large card with six labeled section cards (Rates & Costs,
Facility Overhead, Equipment, Pricing & Profit, Rush Charges, Complexity)
to reduce visual density and improve scannability.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 21:06:04 -04:00
spouliot b2a1b9a0be Remove ShopWorker entity and migrate worker identity to ApplicationUser
Removes the ShopWorker and ShopWorkerRoleCost entities, all related DTOs,
mappings, controllers, views, and import/export paths. Worker identity is
now handled entirely through ApplicationUser with per-user LaborCostPerHour.
ShopWorkerRoleCosts table remains in production pending manual data migration.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 21:06:04 -04:00
spouliot 1a44133a63 Remove ShopWorker entity and migrate worker identity to ApplicationUser
Removes the ShopWorker and ShopWorkerRoleCost entities, all related DTOs,
mappings, controllers, views, and import/export paths. Worker identity is
now handled entirely through ApplicationUser with per-user LaborCostPerHour.
ShopWorkerRoleCosts table remains in production pending manual data migration.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 20:32:32 -04:00
spouliot 7020797a25 Merge dev: tax-exempt pricing fixes, job details Unicode cleanup
- Fix tax-exempt customers being charged tax on all job save/recalc paths (7 call sites in JobsController)
- Fix JS falsy-zero bug in quote preview tax calculation (item-wizard.js)
- Fix quote preview not recalculating on customer change (Create.cshtml)
- Add AddQuotePricingSnapshotFields migration (missing from prior session)
- Fix intake button rendering &#10003; as literal text (Html.Raw fix)
- Clean up corrupted Unicode box-drawing chars in Job Details view

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 16:52:39 -04:00
spouliot 3b5511a703 Fix corrupted Unicode characters and intake button rendering in Job Details
- Replace mojibake box-drawing chars (U+2500 encoded as Windows-1252) with
  plain ASCII dashes throughout all comments in Details.cshtml
- Fix intake button showing literal '&#10003;' text: the entity was inside a
  C# string so Razor HTML-encoded the '&'; switched to Html.Raw() so the
  checkmark renders correctly

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 16:43:36 -04:00
spouliot 8df37ca760 Fix tax-exempt customers charged tax on all job save paths
Jobs used company default TaxPercent for every pricing recalculation
(Create, Edit, UpdateItems, DeleteJobItem) without checking the customer's
IsTaxExempt flag. Added GetEffectiveTaxPercentAsync helper and wired it
into all seven call sites so tax-exempt customers are never billed tax
regardless of which path triggers the recalc.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 16:15:43 -04:00
spouliot 7239f55308 Fix tax-exempt customers always charged tax in quote preview
parseFloat('0') is falsy in JS, so '0 || pageMeta.taxPercent' was
falling through to the company default rate even when the TaxPercent
field was correctly set to 0 for a tax-exempt customer. Use an
explicit field presence check instead.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 16:05:07 -04:00
spouliot 09e077897b Fix quote preview not recalculating when tax-exempt customer is selected
When a customer was changed to/from a tax-exempt customer, the hidden
TaxPercent field was correctly updated to 0 but the live pricing preview
was not re-run, so the display showed a stale total with tax applied.
Selecting a tax-exempt customer now immediately triggers a recalc so
the on-screen total matches the amount that will be saved.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 15:58:20 -04:00
spouliot 051c86810e Add missing AddQuotePricingSnapshotFields migration
Seven new decimal columns on Quotes table that were added to the entity
in the pricing audit but the migration was never created (name collision
with a prior attempt in the previous session caused the scaffold to fail).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 15:48:46 -04:00
spouliot 6721de91e4 Fix pricing consistency across Quote → Job → Invoice; add stage-flow tests
- Store complete PricingBreakdownJson snapshot on Job at every save point so
  the Details page reads stored data rather than re-running the pricing engine
- Add 7 missing fields to Quote entity (FacilityOverheadCost, tier/quote discounts,
  SubtotalAfterDiscount) and persist them via ApplyPricingSnapshot
- Fix OvenCostId-as-rate bug in JobsController (FK was passed as decimal $/hr)
- Fix hardcoded LaborCost * 0.4 multiplier in two JobItemAssemblyService overloads
- Fix FacilityOverheadCost dropped from invoices in both quote and direct-job paths
- Fix RushFee missing from direct-job invoices (read from PricingBreakdownJson)
- Fix Notes and CatalogItemId not copied to InvoiceItem
- Add 14 unit tests in PricingStageFlowTests covering all three pricing stages

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 15:03:06 -04:00
spouliot 226a6237a6 Fix corrupted Unicode characters in Jobs/Details.cshtml
All � replacement characters replaced with correct HTML entities
(&mdash;, &ndash;, &bull;, &times;, &hellip;) and restored a
corrupted class attribute with missing double quotes on the Intake button.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 09:51:00 -04:00
spouliot cf6acc125f Complete mobile card view coverage for all remaining pages
- CSS fix: change blanket .table-responsive hide to only trigger when
  a .mobile-card-view sibling exists (.mobile-card-view ~ .table-responsive
  and :has() rule) — auto-fixes 60+ forms/reports/detail/help pages that
  were showing blank on mobile by making their tables scroll instead
- Add mobile card views to remaining list pages:
  JobsPriority (overdue jobs, main board, maintenance sections)
  NotificationLogs (email/SMS log entries)
  AiUsageReport (per-company AI usage breakdown)
  GiftCertificates/BulkResult (batch certificate list)
  Inventory/SamplePanels (Need to Order + On Wall tabs)
  BannedIps (active bans + lifted/expired bans)
  OnboardingProgress (per-company activation funnel)
  ReleaseNotes/Manage (versioned changelog entries)
  StorageMigration/Results (file migration status list)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 23:31:38 -04:00
spouliot f467862877 Add mobile card views to 12 high-priority list pages
Pages were blank on phones because mobile-cards.css hides .table-responsive
below 992px. Added .mobile-card-view sections to: GiftCertificates, PurchaseOrders,
CreditMemos, VendorCredits, JournalEntries, Appointments, InAppNotifications,
BankReconciliations, FixedAssets, RecurringTemplates, SmsAgreements, SmsConsentAudit.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 23:07:52 -04:00
spouliot 7ad7d84016 Add mobile card views to Invoices and Intakes list pages
Both pages were blank on phones because mobile-cards.css hides .table-responsive
below 992px but neither page had a .mobile-card-view section. Added card-per-row
mobile layout to match the Customers page pattern — tappable cards with status
badges, key fields, and action buttons sized for touch.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 22:51:22 -04:00
spouliot 75b0a8afe2 Fix kiosk inactivity timer for remote sessions; make Intakes table mobile-responsive
Remote sessions (customer's phone) no longer get the 45-second inactivity redirect
that requires a KioskDevice cookie — would have landed them on an error page.
Intakes staff table hides non-essential columns on small screens so the primary
customer/status/actions columns are visible without horizontal scrolling.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 21:00:43 -04:00
spouliot 38748c2152 Add BatchId to GiftCertificate for persistent bulk batch tracking
BatchId (Guid?) is stamped on every certificate in a bulk run so the batch
is permanently addressable. BulkResult is now a bookmarkable GET by batchId
rather than TempData, so users can return to re-download at any time.
BatchDownloadPdf is a GET link (no form POST needed). Index shows a Batch
badge on bulk certs that links directly back to the batch result page.

Migration: AddGiftCertificateBatchId

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 20:32:56 -04:00
spouliot 4ec55e7290 Restore all zeroed views + add bulk gift certificate creation
The HTML entity sweep script had a bug where it wrote empty files for any
view that contained no target Unicode characters, zeroing out 215 view files.
All views restored from the pre-sweep commit (cefdf3e).

Bulk gift certificate feature:
- BulkCreateGiftCertificateDto with Quantity (1-500), Amount, Reason, Expiry, Notes
- GenerateBulkGiftCertificatePdfAsync on IPdfService / PdfService: one Letter page
  per cert, reusing the same purple/gold branded ComposeGiftCertificateContent helper
- GiftCertificatesController: BulkCreate GET/POST, BulkResult GET, BulkDownloadPdf POST
- Views: BulkCreate.cshtml (form with live total preview), BulkResult.cshtml (table +
  Download All PDF button that POSTs cert IDs to avoid URL length limits)
- gift-certificate-bulk.js: live preview + spinner/disable on submit
- Index.cshtml: Bulk Create button added alongside New Certificate

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 20:09:22 -04:00
spouliot 3eda91f170 Replace literal Unicode special chars with HTML entities across all 233 views
Sweeps em dashes, en dashes, multiplication signs, ellipses, and curly quotes
to their HTML entity equivalents (&mdash; &ndash; &times; &hellip; &lsquo; &rsquo;)
in all .cshtml files, skipping <script> blocks. Prevents encoding corruption
from AI tools and Windows encoding mismatches that caused recurring symbol bugs.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 19:16:17 -04:00
spouliot cefdf3e35c Add remaining-weight input mode to inventory scan/usage page
Users can now toggle between 'Amount Used' and 'Remaining Weight' on the
QR scan page. In remaining-weight mode, usage is calculated as
(current stock - remaining) before submit — no controller changes needed.
Includes live hint showing calculated usage and new balance as they type,
with validation preventing negative usage or remaining > current stock.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 19:12:28 -04:00
spouliot f34ee749be Fix garbled encoding symbols in oven display, bill/invoice tooltips, and profile timezone dropdown
- Replace mojibake × with × in oven batch cost row across Jobs Create/Edit/EditItems and Quotes Create/Edit
- Fix Qty × Unit Price tooltip in Bills/Edit and Invoices/Edit
- Fix all â€" (garbled em dash) and São Paulo in Profile timezone dropdown option labels

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 18:54:35 -04:00
spouliot 357ef84001 Fix online users page always showing /InAppNotifications/Recent as current page
The notification bell polls /InAppNotifications/Recent (a JSON endpoint) every time
it loads. Because the middleware throttles updates to once per 60s, the update fired
on whichever request first arrived after the throttle expired — usually the bell poll
rather than a real page navigation. Fix: skip any response whose Content-Type is
application/json so only full page navigations update the current-page field.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 17:16:54 -04:00
spouliot 7a1a697dc2 Merge dev into master: fix oven batch conversion, invoice quantity, AI photo pricing, enforce pricing flag propagation 2026-05-14 16:56:26 -04:00
spouliot 539c6c2559 Fix oven batch conversion, invoice quantity, AI photo pricing, and enforce pricing flag propagation
- Carry OvenBatches/OvenCycleMinutes from Quote → Job entity (was missing fields; all job pricing recalcs hardcoded 1/null)
- Fix invoice creation from job always showing Quantity=1 (was using TotalPrice as UnitPrice with qty 1)
- Add IsAiItem to JobItem + migration; map in all 3 JobItemAssemblyService.CreateJobItem overloads so AI photo jobs no longer double-price on first edit after quote→job conversion
- Propagate IsAiItem through all existingItemsData JSON blocks in Jobs views (Edit, EditItems, Create) so the wizard preserves AI routing on re-edit
- Add PricingRoutingFlags_ExistOnBothQuoteItemAndJobItem structural test + 3 behavioral IsAiItem tests to JobItemAssemblyServiceTests
- Consolidate item wizard partials (_ItemWizardModal, _SqFtCalculatorModal) and item-wizard.css into shared locations
- Document pricing flag propagation checklist in CLAUDE.md

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 16:54:22 -04:00
spouliot a947494cbd Merge dev into master: churned account filter, powder catalog lookup fixes 2026-05-14 14:19:27 -04:00
spouliot 7e79a13cb1 Fix powder catalog lookup: exact match auto-fills, partials show picker modal
- CatalogLookup now returns all partial color name matches ranked by
  specificity (exact vendor+color first, same-vendor partial, cross-vendor)
  with isExact flag so JS can decide to auto-fill vs show modal
- Removed cross-vendor fallback that was silently overwriting manufacturer
  field with wrong brand when vendor-scoped search found nothing
- Picker modal now includes "Not listed — search online" option that
  triggers AI lookup as an escape hatch from the catalog results

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 14:18:52 -04:00
spouliot 2ad6df1195 Hide churned trial accounts from company/health screens by default
- Companies list and Company Health now hide Expired/Canceled accounts
  whose subscription ended 14+ days ago; show/hide toggle via banner
- KPI cards on Company Health exclude churned tenants when hidden
- showChurned param threads through sort, pagination, search, and filter forms
- Powder catalog: fix missing UnitPrice on user-contributed entries;
  add back-sync to fill catalog gaps on existing matches; wire
  AiAugmentFromUrl and manual inventory Create into catalog contribute path

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 13:59:12 -04:00
spouliot dc3cd75ea4 Merge dev into master — prod deploy 2026-05-14
- Real-time SMS consent status update on customer record
- Fix kiosk SMS consent routing loop and stuck tablet
- Fix notification bell, SMS consent kiosk flow, and button alignment
- Add staff-presented SMS consent flow on customer record
- Customer intake kiosk (SignalR → polling, inactivity reset, signature pad, anonymous logo endpoint)
- Invoice SMS notifications
- Kiosk help article and AI knowledge base updates
2026-05-14 08:17:24 -04:00
spouliot a73f14fa7f Real-time SMS consent status update on customer record
When kiosk consent is completed, the staff-facing customer Details page
now updates the SMS badge instantly via SignalR — no page refresh needed.
Added customerId to the NewInAppNotification SignalR payload so the
KioskConsent handler can match the current URL and swap the badge in place.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 23:40:47 -04:00
spouliot 0af31c39b3 Fix kiosk SMS consent routing loop and stuck tablet
- Route param renamed customerId→id so /Kiosk/SmsConsent/15307 binds correctly
  (default MVC route uses {id}; mismatched name caused GetByIdAsync(0)→404→loop)
- Cache entry cleared in GET (not just POST) so returning to Welcome after seeing
  the form never redirects again
- Added POST /Kiosk/CancelSmsConsent for staff to free the kiosk if they pushed
  consent accidentally — Customer Details shows a Cancel button after pushing

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 23:25:37 -04:00
spouliot e1256503be Fix notification bell, SMS consent kiosk flow, and button alignment
Notification bell:
- Bell now polls /InAppNotifications/Recent every 60s as a SignalR fallback
- Bell dropdown refresh on open so count is always current when staff looks at it

SMS consent → kiosk flow:
- Staff clicks "Get SMS Consent" on Customer Details → AJAX POST to
  /Kiosk/PushSmsConsent stores customer in IMemoryCache (10 min TTL)
- Kiosk PollSession returns smsConsentPending + customerId so tablet navigates
  to /Kiosk/SmsConsent/{customerId} automatically
- Customer reads TCPA consent on tablet, taps I Agree or No Thanks
- On agree: NotifyBySms/SmsConsentedAt/SmsConsentMethod set; in-app notification
  fires; cache cleared; tablet returns to Welcome
- Removed Customers/SmsConsent (staff-browser version); moved view to Kiosk/

Button alignment:
- kiosk.css: added display:flex + align-items:center + justify-content:center to
  all kiosk body buttons so content is centred vertically in tall button outlines

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 23:13:57 -04:00
spouliot b69ff6db3a Add staff-presented SMS consent flow on customer record
- New GET/POST Customers/SmsConsent/{id}: full-screen kiosk-layout page staff
  opens and hands to the customer to read TCPA consent language and tap I Agree
- On agreement: sets Customer.NotifyBySms, SmsConsentedAt (UTC), SmsConsentMethod
  = "InPerson", clears SmsOptedOutAt
- Redirects back if customer has already consented (no double-consent)
- Customer Details: "Get SMS Consent" badge link shown when NotifyBySms is false;
  SMS on badge shows consent date on hover when consented

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 22:50:49 -04:00
spouliot 66231822af Update kiosk help article and AI knowledge base for output setting
- Help article: new "Kiosk Output Setting" section explaining Quote vs Job modes and
  the Company Settings → Kiosk tab; Overview updated; Reviewing Submissions now lists
  "View Quote" and "View Job" separately; notification label corrected (Remote vs Walk-in)
- AI knowledge base: CUSTOMER INTAKE KIOSK section updated — output setting documented,
  submission outcome reflects Quote/Job branch, notification labels corrected, workflow
  entries split into Quote-mode and Job-mode variants, troubleshooting updated

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 22:39:39 -04:00
spouliot d5ad9fa073 Add KioskIntakeOutput company setting and fix kiosk submission bugs
- New CompanyPreferences.KioskIntakeOutput setting ("Quote" default / "Job"): controls
  what the kiosk creates on submission; shown as a card-style radio toggle in
  Company Settings → Kiosk tab
- KioskSession.LinkedQuoteId added so quote-first sessions link back to the draft quote
- Migration AddKioskIntakeOutputSetting applies both schema changes
- ProcessSubmissionAsync branches on setting: creates Draft quote (quote-first) or
  Pending job (job-first); save order fixed (CompleteAsync before using DB-assigned Id as FK)
- Terms.cshtml pricing paragraph is now dynamic: "subject to formal quote" for Quote mode,
  "team member will reach out about pricing" for Job mode
- Customer Intakes list: "View Quote" button appears when LinkedQuoteId is set
- Notification label fixed: Remote sessions now say "Remote Intake", not "Walk-in Intake"
- Inactivity reset shortened to 45 s on intake steps
- Signature pad: hosted locally (no CDN), canvas resize deferred via requestAnimationFrame
- AI photo upload: client-side compression to ≤1200px + AbortController 120 s timeout
- Help article and AI knowledge base updated with kiosk feature

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 22:35:37 -04:00
spouliot d134dd51e5 Add Customer Intake Kiosk help article and knowledge base entry
- New Help article at /Help/CustomerIntakeKiosk covering setup, in-person
  and remote intake flows, what happens on submission, reviewing intakes,
  and troubleshooting (signature pad, connection issues, seed data)
- Add kiosk entry to _HelpNav under Operations
- Update HelpKnowledgeBase: nav overview, full kiosk section, two new
  common workflow entries (walk-in kiosk and remote intake)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 22:02:34 -04:00
spouliot 1df7c13abd Sweep kiosk intake submission for FK/null bugs
- Fix Jobs.Id FK violation: save job first with CompleteAsync() to get
  its DB-assigned Id, THEN set session.LinkedJobId and save again.
  Previously job.Id was still 0 when written to the nullable FK column.
- Replace ?? 1 fallbacks on JobStatusId/JobPriorityId with explicit
  InvalidOperationException — hardcoded 1 may not exist in the company's
  lookup tables; now fails loud with a clear message instead of an FK error.
- Add ValidateSessionState check to Terms POST so expired/already-submitted
  sessions don't re-run ProcessSubmissionAsync and create duplicate jobs.
- Null-guard session.JobDescription before slicing for notification snippet.
- Tighten catch block: wrap the fallback CompleteAsync in its own try/catch
  so a secondary failure doesn't mask the original error in logs.
- Swap Job.Description / SpecialInstructions: Description now holds the
  actual job description text; SpecialInstructions records the intake source.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 21:53:10 -04:00
spouliot 4a8778504f Fix FK violation on kiosk intake submission: set JobPriorityId
ProcessSubmissionAsync was creating a Job without JobPriorityId, leaving
it as 0 which violates the FK to JobPriorityLookups. Look up the NORMAL
priority the same way JobsController does everywhere else.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 21:36:53 -04:00
spouliot f1d7054b3e Fix AI quote reliability on mobile: compress photos + add fetch timeout
- Compress photos client-side before uploading (1200px max, JPEG 85%):
  full-res phone photos (5-15 MB) → ~150-250 KB, dramatically reducing
  upload time on slow mobile connections and Anthropic processing time
- Add 120s AbortController to both AiAnalyzeItem fetch calls so a stalled
  mobile connection produces a clear 'timed out' error instead of spinning forever
- After 30s show 'Still analyzing… this can take a minute on mobile' to
  reassure users the request is in progress
- Reset loading text on retry so the slow-connection hint doesn't persist

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 21:22:32 -04:00
spouliot 46b950baf2 Kiosk intake: 45-second inactivity reset to Welcome screen
_KioskLayout inactivity timer now reads ViewBag.InactivityTimeoutMs
(defaults to 5 min). PopulateKioskViewBagFromSession sets it to 45 s
on every intake step so an abandoned form auto-returns to the waiting
screen. Welcome screen and Confirmation page are unaffected.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 21:16:34 -04:00
spouliot 4e9c9d321a Fix kiosk signature pad: host locally, fix canvas resize timing
- Download signature_pad 4.1.7 to wwwroot/lib/signature-pad/ to eliminate
  CDN SRI hash failures and network dependencies on the tablet
- Wrap resizeCanvas in requestAnimationFrame so offsetWidth is non-zero
  when measured (browser layout pass must complete first)
- Add guard for SignaturePad not defined (shows user-visible error instead
  of silent JS crash)
- Add scrollIntoView on signature validation error for better tablet UX

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 21:14:49 -04:00
spouliot 0c8723ef84 Fix sw.js: exclude /hubs/ and PollSession from SW interception
SW fetch() wraps SSE responses in a buffered Response, preventing SignalR
streaming — handshakes time out after 15s as a result. Exclude /hubs/ and
/Kiosk/PollSession so the browser handles them directly without SW wrapping.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 20:45:03 -04:00
spouliot 377bb1ce38 Replace kiosk SignalR with polling — Azure App Service blocks anonymous hub handshakes
SignalR WebSocket and SSE both receive immediate 'Handshake was canceled' from the
server-side hub context. The 15-second delay between negotiate and SSE connect
reveals the handshake timer has expired before the transport opens — caused by Azure
App Service's ingress proxy resetting anonymous long-lived connections.

Replacement: /Kiosk/PollSession (anonymous GET, no-cache) queried every 3 seconds.
Returns the most recent Active InPerson session created in the last 60 seconds.
The kiosk navigates when hasSession=true. Status dot: gray->green on first success,
yellow on network error, blue when navigating. Removed signalr.min.js from kiosk layout.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 20:37:28 -04:00
spouliot 2acf54e1a9 Fix kiosk SignalR: skip WebSocket transport, add hubs/Kiosk to subscription bypass
1. kiosk-welcome.js: force SSE|LongPolling transport on the kiosk hub.
   Azure App Service's ingress proxy cancels anonymous WebSocket handshakes
   before the SignalR protocol exchange completes. SSE and long polling
   work fine for the low-frequency StartIntake push this hub needs.

2. SubscriptionMiddleware: add /hubs/ and /Kiosk/ to SkipPaths so a
   subscription redirect can never fire on a hub or kiosk request and
   abort the connection mid-handshake.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 20:20:06 -04:00
spouliot 0b24c320cd Fix kiosk intake routing, view names, and SignalR diagnostics
Three bugs identified:
1. Routing: /Kiosk/Intake/{token}/{action} had no matching route — 4-segment
   URL fell through the default 3-segment {controller}/{action}/{id?} route.
   Added explicit kiosk_intake route in Program.cs.

2. View names: Contact/Job/Terms/Confirmation actions returned View(model)
   which resolved to Views/Kiosk/{Action}.cshtml — those files don't exist.
   Views live in Views/Kiosk/Intake/. Fixed all six return statements.

3. Diagnostics: conn dot now starts gray ("Connecting...") and turns green
   only when SignalR actually connects. Red + message if no company ID or
   connection fails. Makes it easy to confirm the hub connection is live.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 18:28:16 -04:00
spouliot 350f2d7658 Fix duplicate logo on kiosk Welcome screen; enlarge welcome logo
Layout rendered a small logo at top of every kiosk page. Welcome.cshtml
also rendered its own centered logo, resulting in two logos. Suppress
the layout logo on the Welcome screen via HideLayoutLogo ViewBag flag.
Bump kiosk-welcome-logo from 120x280 to 200x420 for better presence.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 17:21:27 -04:00
spouliot 856d202b78 Fix kiosk SignalR group: set ViewBag.CompanyId so tablet joins correct hub group
Without this, data-company-id was empty and the JS connected to kiosk- (no ID),
so StartSession signals never reached the tablet.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 17:18:39 -04:00
spouliot 8caaa84eac Hide Start Intake button when kiosk not activated; relabel remote link
- Start Intake button only shows when company has an active kiosk token
- Remote Link button renamed to "Send Intake Link" for clarity

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 17:00:09 -04:00
spouliot e70f7ee9f1 Fix kiosk logo: add anonymous Logo endpoint proxying blob storage
CompanySettings/Logo requires tenant context and fails on anonymous
kiosk pages. Added Kiosk/Logo which resolves the company from the
KioskDevice cookie and proxies the blob directly.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 16:55:44 -04:00
spouliot 6a918c2afc Add invoice SMS notifications and customer intake kiosk
Invoice SMS:
- Send Invoice modal now prompts Email/SMS/Both based on customer contact data
- New /invoice/{token} customer-facing view page with full line items and pay button
- PublicViewToken (permanent) added to Invoice; separate from expiring PaymentLinkToken
- InvoiceSent SMS default template added; customizable via Notification Templates settings
- {{viewUrl}} placeholder documented in template editor

Customer Intake Kiosk:
- Tablet kiosk flow: Contact → Job → Terms/Signature → Confirmation
- Remote link mode for off-site customers (lighter form, no signature)
- KioskHub (AllowAnonymous SignalR) for staff-to-tablet push without login
- Staff activates tablet via cookie; sends remote link manually
- Submitted sessions create Customer + Job automatically; fires in-app notification

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 16:25:27 -04:00
spouliot 27bfd4db4d Close all GL entry gaps across the accounting surface
- Stripe payments/refunds/chargebacks now post DR/CR entries (PaymentController)
- Vendor credit void now reverses the posted GL lines (VendorCreditsController)
- Gift certificate issue/redeem/void post GL to account 2500 GC Liability;
  FinancialReportService Trial Balance + Balance Sheet include GC liability and
  breakage income; P&L shows deferred revenue deduction and breakage income line
- Customer deposits now post DR Checking / CR 2300 on record, reverse on delete;
  invoice auto-apply uses DR 2300 / CR AR (not a second bank debit); draft
  invoice delete reverses deposit-apply GL before the AR reversal
- Deposit.DepositAccountId column added; account 2300 seeded via migration
- InvoicesController.ApplyCredit now posts DR Sales Discounts / CR AR,
  consistent with CreditMemosController.Apply
- IssueRefund (cash/card) posts DR AR / CR Bank and sets Refund.DepositAccountId;
  refund modal gains a bank account selector hidden for store-credit path
- CancelRefund (cash/card) reverses the IssueRefund GL entries
- LedgerService GetAccountLedgerAsync + ComputePriorBalanceAsync now include
  Refunds, CreditMemoApplications, VendorCreditApplications, GC Liability (2500),
  and Customer Deposits (2300) so account ledger view and RecalculateAllAsync
  produce correct balances
- Three EF migrations applied: SeedSalesDiscountsAccount, AccountingGapsPhase2,
  AccountingDepositsGL
- Unit tests updated for new IAccountBalanceService constructor params (200/200)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 12:42:46 -04:00
spouliot 787d1504ef Label onboarding status and path badges on company detail
The two floating badges in the Onboarding > Setup Progress card had no
context — "Not Started" and a path name appeared without explanation.
Added inline "Status:" and "Path:" labels so both badges are immediately
readable without needing a tooltip.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 22:32:08 -04:00
spouliot 726bebdce9 Consolidate company admin screens: health badge on list, tabbed detail page
Companies/Index:
- Added Health badge column (Healthy / At Risk / Critical / Never Active)
  with the numeric score in a tooltip; computed from the same signals as
  CompanyHealth/Index using the new shared CompanyHealthHelper

Companies/Details:
- Converted flat card layout to five tabs: Overview, Users, Subscription,
  Onboarding, Health; URL hash is preserved so the active tab survives
  page refresh and back navigation
- Subscription tab shows plan/status/dates with an expiry countdown and a
  "Manage Subscription & Features" button to the full Manage page
- Onboarding tab shows wizard completion, milestone progress bar, and
  first-activity dates (previously only on the standalone page)
- Health tab shows score gauge, risk badge, and individual risk signals
  with a link through to the full CompanyHealth dashboard
- JS moved to wwwroot/js/companies-details.js (avoids inline-script failures)

Infrastructure:
- Extracted ComputeHealth / ToRiskLevel / ChurnRisk to CompanyHealthHelper.cs
  (same Controllers namespace); CompanyHealthController delegates to it
- CompanyCountSummary extended with Jobs30Counts, Jobs90Counts, LastLoginDates
  (3 extra GROUP BY queries scoped to the current page IDs, not all companies)
- CompanyListDto gains HealthScore, HealthRisk, LastLoginDate

Navigation:
- Removed "Onboarding Progress" hub card from People & Activity; the data
  is now surfaced directly on the Companies/Details Onboarding tab

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 22:22:14 -04:00
spouliot 786b78e502 Add Online Now shortcut to Platform Admin nav with live user count
Adds a direct link to UserActivity/Online immediately below the People
& Activity hub entry so the current active-user count is visible at a
glance without navigating into the hub first. The count badge is
rendered using the already-injected OnlineUserTracker.GetActiveCount()
call and is hidden when the count is zero.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 21:49:37 -04:00
spouliot cb1b6dceb6 PR 5 follow-up: boolean radio buttons and seed missing settings
- Replace true/false text display with Yes/No radio button groups for
  boolean platform settings; toggling auto-submits the form so no Edit
  modal is needed for flags
- IsBool() helper detects *Enabled, *AppliesToTrials, *TrialsEnabled keys
- Hide Edit button for boolean settings (radio buttons are the control)
- Add AI group icon (bi-robot) and description to the group header switch
- Add Max key detection to InputType/InputHint for number inputs
- Migration AddMissingPlatformSettings seeds 6 previously missing rows
  (SmsEnabled, TrialsEnabled, GracePeriodDays, GracePeriodAppliesToTrials,
  MaxTenants, AiCatalogPriceCheckEnabled) using IF NOT EXISTS guards

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 21:40:16 -04:00
spouliot fb31fa7eb3 PR 5: Platform Settings UI redesign
- Section headers now show a group-specific icon, colored icon tile, and a one-line description explaining what each group controls (General, Notifications, Subscriptions, Quotes, Data Retention)
- Each setting now displays UpdatedAt and UpdatedBy metadata below the current value so operators can see when and by whom a setting was last changed
- Edit modal now uses type-appropriate inputs: number (with min=0, step=1) for *Days keys, email for *Email keys, url for BaseUrl, text otherwise; each type shows a contextual hint
- Key name shown in monospace below the label on desktop for operator reference
- Added SuccessMessage TempData alert at the top of the page
- No backend or DB changes — view-only redesign

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 21:19:52 -04:00
spouliot 637be701ea PR 4: Production guardrails for Seed Data and Storage Migration
- SeedData/Index: added prominent danger banner when running in Production (environment include="Production") so operators are clearly warned before writing to the live database; page remains accessible since seeding is occasionally valid in prod for new company onboarding
- StorageMigration/Index: added warning banner in Production explaining the tool is not needed now that Azure Blob migration is complete
- PlatformAdminController: hide Storage Migration hub card in Production via ShowStorageMigration flag (same WEBSITE_SITE_NAME pattern as ShowRawLogFiles); Seed Data card remains visible so prod onboarding stays reachable

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 21:08:14 -04:00
spouliot e9cd67f5d9 PR 3: Observability back-links and terminology cleanup
- Added an Observability back-link at the top of all 7 Observability-area pages (AuditLog, SystemLogs, SystemInfo, AiUsageReport, UsageQuota, BannedIps, Diagnostics/Index) consistent with the Maintenance back-link pattern from PR 2
- Renamed company-level nav label and view title from "Notification Log" to "Email & SMS Log" to distinguish it clearly from the platform-level "Platform Notifications", "Audit Log", and "System Logs" surfaces

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 20:55:58 -04:00
spouliot 433090effd PR 2: normalize DiagnosticsController auth; add Maintenance back-links
- DiagnosticsController: replaced raw [Authorize(Roles = "SuperAdmin,Administrator")] with [Authorize(Policy = SuperAdminOnly)] to match every other platform-admin controller; added PowderCoating.Shared.Constants using directive
- DataPurge, DataExport, StorageMigration, SeedData: added "← Maintenance" breadcrumb link at the top of each page so operators know they are in the guarded maintenance area and can navigate back to the hub

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 20:50:11 -04:00
spouliot 4ca90f561e Fix price override ignored for catalog items; fix time entry delete UI
- QuotePricingAssemblyService: catalog no-coat items now respect PowderCostOverride instead of always using DefaultPrice
- PricingCalculationService: removed duplicate catalog fast-path in CalculateQuoteTotalsAsync that bypassed CalculateQuoteItemPriceAsync (and thus ignored PowderCostOverride); all items now go through the single authoritative path
- Jobs/Details.cshtml: timeTracking.del() and timeTracking.save() now call costing.load() after success so the time entry row and costing breakdown stay in sync

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 20:38:49 -04:00
spouliot f95397204c Fix platform admin subtle badge styling 2026-05-12 10:16:20 -04:00
spouliot 31d305b66a Group platform admin tools into hub pages
- add grouped platform admin hub pages, view models, and shared card UI\n- simplify the super admin nav and dashboard quick links around the new hubs\n- fix the AiQuoteService EstimatedMinutes assignment so the infrastructure project builds cleanly
2026-05-12 09:03:18 -04:00
spouliot 42a8c089d5 Fix AI photo quote always returning shop minimum price
Two bugs caused AI estimates to collapse to the shop minimum floor:

1. Coating rate with no guard: when a shop hadn't calibrated their
   coating gun (rate = 0), the prompt injected '~0 sqft/hr' paired
   with 'MUST use shop-specific rates' — Claude returned near-zero
   estimatedMinutes, zeroing labor cost and triggering the floor.
   Fixed to mirror the existing blast-rate guard: rate=0 now sends
   a fallback instruction to use conservative industry-average times.

2. Per-item minutes divided by quantity: both the system prompt and
   user prompt explicitly tell Claude to return estimatedMinutes 'per
   single item', but CalculatePricingPreview() was dividing by qty
   anyway. For qty > 1 this halved (or more) the labor cost, again
   pushing toward the floor. Removed the incorrect divide.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 21:10:05 -04:00
spouliot 2c353f2e7f Three small UX fixes: customer contact validation, pagination dropdown width
- Customer validation now accepts Mobile Phone as a valid contact method;
  previously only Email or Phone satisfied the requirement
- Create customer hint text updated to match the new validation rule
- Pagination page-size dropdown gets min-width: 5rem so the number has
  breathing room from the caret arrow across all paginated lists

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 18:20:43 -04:00
spouliot c02a5584b4 Hide Converted quotes from default listing to reduce clutter
The Quotes index now excludes Converted status by default so the list
stays focused on active work. Converted quotes remain accessible via
the status filter dropdown. Selecting any explicit status filter
(including Converted) bypasses the exclusion as expected.

Also consolidated the two GetQuoteStatusLookupsAsync calls into one
at the top of the action since the cache makes it free.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 16:45:09 -04:00
spouliot 17da692dce Fix two production billing bugs: invoice missing oven cost, quote stuck in Draft after send
Bug 1 — Invoice total didn't match job total for direct jobs:
- Root cause: all three item-save paths in JobsController passed null for
  ovenCostId, so FinalPrice/ShopSuppliesAmount were stored without oven cost
  while the Details page recalculated live with OvenCostId and showed higher.
- Add OvenBatchCost stored field to Job entity (migration AddJobOvenBatchCost,
  default 0 for existing rows).
- Fix Create, Edit, and UpdateItems to pass job.OvenCostId and save OvenBatchCost.
- Fix InvoicesController.Create GET for direct jobs to use stored OvenBatchCost
  and ShopSuppliesAmount as separate labeled lines instead of recalculating
  shop supplies from scratch (which excluded the oven cost base).

Bug 2 — Quote status stayed Draft after "Send Quote via Email":
- ResendQuote advanced the approval token and sent the email but never
  updated the status. Added Draft → Sent advancement (same guard used by
  the SMS send path) so the status updates on successful email send.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 10:39:49 -04:00
spouliot 656f830898 Add permission descriptions and role defaults to CompanyUsers Create/Edit
- Added form-text blurbs under every permission checkbox on both pages
  so admins know exactly what each permission unlocks at a glance
- Replaced single Accountant default with a full roleDefaults map covering
  Viewer, Worker, Accountant, and Manager roles
- Create page applies defaults on load and on role change (fresh form)
- Edit page preserves saved permissions on load; only resets to defaults
  when the role is explicitly changed

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 08:26:21 -04:00
spouliot dde66c807f Update help docs and AI knowledge base for Accountant role and new permissions
- Settings.cshtml: add Accountant to roles table with description; add
  Fine-Grained Permissions subsection with a full table of all 16 permissions
  including the new Can Manage Bills & AP and Can Manage Accounting entries
- HelpKnowledgeBase.cs: add Accountant to ROLE AWARENESS section at top;
  add Accountant to USER MANAGEMENT roles list with auto-checked permissions
  note; add Fine-grained permissions paragraph documenting CanManageBills,
  CanManageAccounting, and Accountant role defaults

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 19:44:09 -04:00
spouliot feff0fa73d Add Accountant role and CanManageBills/CanManageAccounting permissions
- AppConstants: add Accountant to CompanyRoles; add CanManageBills and
  CanManageAccounting to Policies
- ApplicationUser: add CanManageBills and CanManageAccounting bool fields
- UserManagementDtos: expose new fields in all three DTOs
- ClaimsPrincipalFactory: emit ManageBills and ManageAccounting claims
- Program.cs: add CanManageBills and CanManageAccounting policies;
  update CanManageInvoices, CanViewReports, CanManagePurchaseOrders,
  and CanManageVendors to auto-pass for Accountant role
- BillsController: replace CanManageInventory with CanManageBills on
  all write actions (correct policy — bills are not inventory)
- BankReconciliationsController: replace CanManageJobs with
  CanManageAccounting on write actions
- CompanyUsersController: add Accountant to validCompanyRoles (both
  Create/Edit), legacyRole switch, and all permission assignment blocks
- Create/Edit views: add Accountant option to role dropdown; add
  CanManageBills and CanManageAccounting checkboxes; JS auto-checks
  financial permissions when Accountant role is selected
- Migration AddAccountantRolePermissions: adds columns + backfills
  CanManageBills=1 and CanManageAccounting=1 for all CompanyAdmin users

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 19:42:53 -04:00
spouliot 59beba2e15 Update help docs and AI knowledge base for 4 new AI bookkeeping features
- Reports.cshtml: added AI Payment Risk Prediction, Ask Your Financials,
  and Bank Rec Auto-Match subsections under AI-Powered Reports; updated
  on-this-page nav with sub-links for all three
- AccountsPayable.cshtml: added Recurring Bill Detection section explaining
  pattern cards, frequency types, confidence badges, next expected date,
  and the 2-occurrence minimum
- HelpKnowledgeBase.cs: added Recurring Bill Detection to BILLS section;
  added AI Payment Risk Prediction and Ask Your Financials to REPORTS
  available-reports list; added features 12–15 to AI FEATURES section
  (Recurring Detection, Payment Risk, Financial Query, Bank Rec Auto-Match)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 19:30:39 -04:00
spouliot 959e323f3a Add 4 AI bookkeeping features
Feature 7: Bank Rec Auto-Match — AiSuggestMatches endpoint scores uncleared
transactions vs statement ending balance; AI Auto-Match panel in Reconcile.cshtml
with confidence highlights and Apply All button.

Feature 8: Late Payment Prediction — PredictLatePayments endpoint scores open AR
customers by risk (high/medium/low) using historical avg-days-to-pay + late rate;
rendered as badge table in AR Aging view via ar-aging-ai.js.

Feature 9: Natural Language Financial Queries — FinancialQuery GET page + RunFinancialQuery
POST; 12-month context snapshot pre-loaded; answers grounded in real data with
supporting facts, follow-up suggestions, session history, and example chips.

Feature 10: Recurring Bill Detection — RunRecurringDetection scans 12 months of bills
for vendor payment patterns (monthly/quarterly/annual); card grid view in Bills/RecurringDetection.cshtml
with confidence badges, next-expected-date, and suggested actions.

Supporting: 4 new DTO groups in AccountingAiDtos.cs, 4 method signatures in
IAccountingAiService.cs, 4 implementations in AccountingAiService.cs, 4 new
AiFeatures constants, 2 new Landing page AI report cards.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 19:22:49 -04:00
spouliot e2f9e9ae4f Button consistency sweep + mobile responsiveness patches
- Standardize modal dismiss/cancel buttons to btn-outline-secondary across 70+ views
- Remove btn-sm from page-level Create and Back buttons (Index + Detail pages)
- Fix Edit buttons on Details pages: btn-secondary -> btn-warning
- Fix form Cancel/Back links: btn-secondary -> btn-outline-secondary
- Add 10 CSS patches to site.css for mobile/tablet responsiveness:
  top-navbar overflow prevention, page-header flex-wrap at 575px,
  table action button min-height override, notification dropdown width cap,
  tablet content padding

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 19:04:10 -04:00
spouliot 328b195127 Design consistency audit fixes: alerts, cards, dark mode, utilities
Alert sweep (113 alerts, 79 files):
  All persistent static banners now carry alert-permanent so the
  layout's 5-second auto-dismiss cannot swallow guidance, warnings,
  or validation errors. Transient dismissible toasts left untouched.

CSS fixes (site.css):
  .card.shadow-sm      — strips rogue border from ~40 drifted cards
  .card-header.bg-white — rebinds to var(--bs-body-bg) so card
                          headers follow dark/light theme correctly
  Typography utilities  — .text-2xs (.68rem), .text-xs (.73rem)
  Token color classes   — .text-ember, .text-ok, .text-bad,
                          .text-warn, .text-cool, .bg-paper-2
  Layout utilities      — .mw-xs/sm/md/lg replace inline max-width
  Comment              — documents text-ember vs text-primary intent

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 18:05:29 -04:00
spouliot f6d457fe0e Condense Operations sidebar: remove 5 items, tighten padding
Layer 1 — relocate off nav:
  Shop Display + Shop Mobile → Jobs page header (split dropdown on Blank Work Order)
  Powder Insights → Inventory page header button

Layer 2 — remove orphan section headers:
  "Main Menu" (only had Dashboard under it)
  "Reports" (only had Reports link under it)

Layer 3 — CSS density:
  nav-link padding 0.75rem → 0.55rem vertical
  nav-section-title padding 1rem/0.5rem → 0.65rem/0.3rem

Operations mode: 27 elements → 22. Scrollbar eliminated on standard screens.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 13:57:09 -04:00
spouliot c65445b94e Move Job Templates from sidebar nav to Jobs page header button
Templates button sits alongside Board and Blank Work Order in the
Jobs index action bar. Nav item and the now-redundant "& Templates"
section title are removed, trimming one more item from the sidebar.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 13:49:10 -04:00
spouliot ccb094e57a Fix nav mode strip: underline-tab style + stable scrollbar gutter
Replace button-style strip with proper underline tabs — active side gets
an ember bottom-border matching the sidebar's existing active-link
language. Add scrollbar-gutter: stable so the sidebar interior width
does not shift when the scrollbar appears/disappears between modes.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 13:45:55 -04:00
spouliot 0204430fa5 Add Operations/Finance mode switcher to sidebar nav
Two-tab strip between Dashboard and the Operations section lets users
toggle between the shop-management view and the accounting view.
FOUC-prevention: server-side controller detection stamps data-nav-mode
on <html> before first paint; CSS hides the inactive side instantly.
JS (nav-mode.js) auto-switches to Finance when navigating to an
accounting controller, restores saved preference everywhere else.
Vendors appears in both modes; all 39 nav items carry data-nav tags.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 13:40:14 -04:00
spouliot 4fd9c52aaf Phase G: Add Budgeting and Year-End Close
Budgeting:
- Budget + BudgetLine entities with Jan–Dec monthly columns per GL account
- BudgetsController: Index, Create, Edit, SetDefault, Copy, Delete
- Copy action rolls a budget forward to a new fiscal year
- Budget vs. Actual report (BudgetVsActual): compares monthly budget amounts to
  real P&L by calling GetProfitAndLossAsync once per month; variance shown as
  favorable/unfavorable; year + budget selectors in header
- Views: Budgets/Index, Create, Edit with inline annual totals via budget-edit.js
- Nav link + report card on Landing

Year-End Close:
- YearEndClose entity records each closed year + JE reference for audit trail
- AccountsController.YearEndClose GET (history + form) + CloseYear POST
- Close zeroes all Revenue and Expense/COGS account balances into Retained Earnings
  via IAccountBalanceService and posts a supporting JE dated Dec 31
- Idempotency: rejects attempt to close an already-closed year
- Pre-close checklist in view to guide the workflow
- Nav link under Finance

Migration AddBudgetsAndYearEndClose applied

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 13:01:56 -04:00
spouliot fde24b09c9 Phase F: Add Invoice Write-Off, Fixed Assets, Period Locking, and 1099 Tracking
- Invoice Write-Off: WriteOff POST action in InvoicesController posts bad-debt JE
  (DR bad debt expense / CR AR), reduces customer balance, marks invoice WrittenOff;
  write-off modal added to Invoice Details view with expense account selector
- Fixed Assets: FixedAsset + FixedAssetDepreciationEntry entities with straight-line
  depreciation; FixedAssetsController (Index/Create/Edit/Details/PostDepreciation/Delete);
  PostDepreciation auto-generates one JE per asset per period, skips already-posted,
  fully-depreciated, and disposed assets; full CRUD views + nav link
- Period Locking: Company.BookLockedThrough field; AccountingPeriodValidator static helper;
  lock check added to JE Post and Bill Create (blocks backdating into closed periods);
  SetPeriodLock action + date picker UI in Company Settings Accounting section
- 1099 Tracking: Is1099Vendor flag on Vendor entity + DTOs; checkbox in Create/Edit views;
  TaxReporting1099 report action + view lists payments by year, flags vendors >= $600;
  report card added to Reports Landing
- Migration AddFixedAssetsLockAnd1099 applied

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 12:19:32 -04:00
spouliot a255893ada Add Credit Memos standalone management module
CreditMemosController with Index, Details, Create, Apply, and Void actions.
All business logic (atomic apply transaction, RemainingBalance cap,
customer.CreditBalance adjustment, auto-Paid invoice when BalanceDue hits zero)
mirrors the invoice-centric IssueCreditMemo/ApplyCredit/VoidCreditMemo actions in
InvoicesController but redirects back to the credit memo rather than an invoice.

Views: Index (stats bar, status+search filter, table), Details (two-col layout
with application history table and Bootstrap Apply/Void confirm modals),
Create (customer dropdown, amount, reason, notes, optional expiry).

Apply modal populates amount automatically from min(remaining credit,
invoice balance due) via credit-memo.js data-attribute wiring (no inline scripts).

Nav: Credit Memos added to Billing & Payments section in _Layout.

Build: 0 errors. Unit tests: 200/200.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 11:48:35 -04:00
spouliot d94612cc9c Fix 4 post-review issues found in accounting module audit
- Drop orphan VendorCreditId1 column from VendorCreditApplications (was
  scaffolded by EF because WithMany() lacked inverse navigation name;
  fixed WithMany() → WithMany(vc => vc.Applications) in ApplicationDbContext)
- Wire EarlyPaymentDiscount fields through full data path: added
  EarlyPaymentDiscountPercent/Days to CreateInvoiceDto, hidden inputs to
  Invoice Create view, and JS to populate from customer AJAX response
- Add missing [HttpGet] attribute to TaxRatesController.Index
- Document GenerateNow architecture exception with XML rationale

Migration DropOrphanVendorCreditId1 applied. Build: 0 errors, 168 warnings.
Unit tests: 200/200 passing.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 11:32:44 -04:00
spouliot 14026818e2 Phase H: Add Cash Flow Statement (direct / cash-basis method)
- CashFlowStatementDto (Operating, Investing, Financing sections; BeginningCash/EndingCash)
- CashFlowLineDto for Investing/Financing line items
- GetCashFlowStatementAsync on IFinancialReportService + implementation in FinancialReportService
- GenerateCashFlowStatementPdfAsync on IPdfService + QuestPDF implementation in PdfService
- ReportsController.CashFlowStatement GET + CashFlowStatementPdf GET with inline/download mode
- CashFlowStatement.cshtml view with date filter, 3-section cards, summary sidebar, methodology note
- Reports Landing page: Cash Flow Statement card added to Accounting section

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 11:14:47 -04:00
spouliot 42eff3357e Phase G: Add Recurring Transactions (BackgroundService + CRUD UI)
- RecurringTemplate entity with Frequency/IntervalCount/NextFireDate/EndDate/MaxOccurrences/TemplateData JSON
- RecurringFrequency + RecurringTemplateType enums
- RecurringTransactionService BackgroundService: hourly check, creates Draft bills or immediate expenses, advances NextFireDate, auto-deactivates on limits
- RecurringTemplatesController: Index/Create/Edit/ToggleActive/Delete/GenerateNow (on-demand fire)
- Three views + external JS for type-toggle and dynamic bill line items
- Finance sidebar nav: Recurring Transactions
- Migration: AddRecurringTemplates

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 11:08:36 -04:00
spouliot d3a5d827f9 Phase F: Customer/Vendor Statements, Payment Terms Parser, Tax Rates
F1: GetCustomerStatementAsync/GetVendorStatementAsync on IFinancialReportService;
    StatementLineDto; CustomerStatementDto/VendorStatementDto; Statement action on
    CustomersController + VendorsController; Statement views + PDF download via
    StatementPdfHelper (QuestPDF); Statement button on Customer/Vendor Details pages.

F2: PaymentTermsParser static helper (CalculateDueDate, ParseEarlyPaymentDiscount);
    EarlyPaymentDiscountPercent/Days on Invoice entity; GetCustomerPaymentTerms AJAX
    endpoint on InvoicesController auto-populates Terms + due date on customer select;
    early payment discount notice on Invoice Create.

F3: TaxRate entity (Name/Rate/State/IsDefault/IsActive, tenant-filtered);
    IUnitOfWork.TaxRates + UnitOfWork + ApplicationDbContext; TaxRatesController
    (Index/Create/Edit/Delete/ToggleActive, CompanyAdminOnly); GetTaxRateForCustomer
    AJAX endpoint; Tax Rates in Settings gear menu.

Also fixes AddVendorCredits migration: VendorCreditApplications FKs changed from
CASCADE to NoAction to resolve SQL Server error 1785 (multiple cascade paths).
Migration: AddPaymentTermsAndTaxRates applied locally; 200/200 unit tests pass.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 10:55:22 -04:00
spouliot 1229081436 Phase E: Add Bank Reconciliation
- IsCleared + ClearedDate added to Payment, BillPayment, Expense entities
- BankReconciliation entity (account, statement date, beginning/ending balance, status)
- BankReconciliationStatus enum (InProgress, Completed)
- Migration AddBankReconciliation: new BankReconciliations table + IsCleared/ClearedDate columns
- IUnitOfWork/UnitOfWork wired with BankReconciliations repo
- BankReconciliationsController: Index, Create, Reconcile, ToggleCleared (AJAX), Complete, Report
- Reconcile view: deposit/payment checkboxes with live running balance and difference via JS
- Complete is gated: only enabled when difference == $0.00
- Nav: Bank Reconciliation added to Finance section in _Layout

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 00:10:38 -04:00
spouliot cf9dcfb4c1 Phase D: Add Vendor Credits (AP cycle completion)
- VendorCredit, VendorCreditLineItem, VendorCreditApplication entities
- VendorCreditStatus enum (Open, PartiallyApplied, Applied, Voided)
- Migration AddVendorCredits: three new tables
- IUnitOfWork/UnitOfWork wired with all three repositories
- VendorCreditsController: Index (status tabs), Create, Details, Post, Apply, Void
- Post action: DR AP, CR each expense line (reverses original expense)
- Apply action: links credit to bill, updates Bill.AmountPaid and bill status
- Views: Index (summary cards + table), Create (dynamic line grid), Details (apply panel)
- Nav: Vendor Credits added to Finance section in _Layout

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 00:03:14 -04:00
spouliot a33687f7bd Phase C: Add Manual Journal Entries (double-entry GL)
- JournalEntry + JournalEntryLine entities with Draft/Posted/Reversed lifecycle
- JournalEntryStatus enum (Draft, Posted, Reversed)
- Migration AddJournalEntries: two new tables with self-referencing reversal FK
- IUnitOfWork/UnitOfWork wired with JournalEntries + JournalEntryLines repos
- ApplicationDbContext: DbSets, tenant query filters, reversal FK config
- LedgerService: JE lines added as 10th source in GetAccountLedgerAsync and ComputePriorBalanceAsync
- JournalEntriesController: Index (All/Draft/Posted tabs), Create, Details, Post, Reverse, Delete
- Views: Index, Create (dynamic balanced line grid with running debit/credit totals), Details
- journal-entry-create.js: dynamic line management with balance indicator
- Nav: Journal Entries added to Finance section in _Layout

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-09 23:56:03 -04:00
spouliot 0afb474c3e Add Phase B: Inventory COGS auto-posting to GL on JobUsage transactions
When powder is consumed via a job (JobsController) or scan (InventoryController.LogUsage),
debit the item's CogsAccountId and credit its InventoryAccountId for the cost of the
quantity consumed (using AverageCost if available, else UnitCost). No-op when either
GL account is not configured on the InventoryItem.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-09 23:39:23 -04:00
spouliot 7e1676cfd7 Add Phase A accounting features: AP Aging, Trial Balance, Cash vs Accrual
- AP Aging report (GetApAgingAsync, controller actions, view, PDF export)
  mirrors AR Aging — groups open bills by vendor, buckets by days past due date
- Trial Balance report (GetTrialBalanceAsync, view, PDF export)
  uses Account.CurrentBalance, groups by AccountType, validates debits == credits
- Cash vs Accrual accounting method setting on Company entity
  switchable at any time — report-time only, no GL re-posting on change
  P&L cash: revenue = payments received; expenses = bills/expenses paid in period
  Balance Sheet cash: omits AR and AP lines (no receivables/payables concept)
  AccountingMethod badge shown on P&L and Balance Sheet views
- Migration A (AddAccountingMethod) applied, default = Accrual for all existing companies
- AP Aging and Trial Balance added to Reports Landing page

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-09 23:34:54 -04:00
spouliot 379b0de885 Refactor: centralize accounting helpers, status constants, and query deduplication
- AccountingDropdownHelper: wired into BillsController and ExpensesController,
  replacing 35-40 lines of duplicated DB queries per controller
- AppConstants.StatusCodes: added Job.* and Quote.* constants to replace all
  magic status strings across Jobs, Quotes, Appointments, OvenScheduler,
  AiQuickQuote, QuoteApproval, and AccountingDropdownHelper
- AccountingRules: extracted IsNormalDebitBalance into shared Infrastructure
  helper; removed duplicate private method from AccountBalanceService and
  LedgerService (~50 lines deleted)
- AccountDataExportController: extracted 9 Fetch*Async methods (superset of
  includes) so Add*Sheet and Build*Csv no longer duplicate DB queries; each
  entity is queried once regardless of whether XLSX or CSV format is requested
- BillsController.Create and ExpensesController.Create wrapped in
  ExecuteInTransactionAsync; blob uploads moved after commit to keep
  financial data atomic and prevent orphaned blobs from rolling back
- Number generators (Appointments, CreditMemo, OvenBatch) fixed from full-table
  GetAllAsync to prefix-filtered FindAsync

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-09 22:42:39 -04:00
spouliot edd7389d7d Refactor: extract shared helpers, fix field drift, add assembly services
- IJobItemAssemblyService / IQuotePricingAssemblyService: centralize job item
  and quote pricing construction that was duplicated across create, rework copy,
  and quote-to-job conversion paths
- BlobFileHelper: single ValidateUpload/GetContentType/SanitizeFileName used by
  6 blob services (JobPhoto, QuotePhoto, ProfilePhoto, CompanyLogo, Equipment,
  Catalog) and BillsController + ExpensesController, removing 8 private copies
- PagedResult<T>.From(): static factory eliminates 6-line boilerplate in 11
  controllers (Appointments, Customers, Equipment, Inventory, Invoices, Jobs,
  Maintenance, CompanyUsers, PlatformUsers, Quotes, Vendors)
- AccountingDropdownHelper: single LoadAsync() call replaces duplicate
  vendor/account/job queries in BillsController and ExpensesController
- JobTemplateItem: add IsSalesItem + Sku fields with migration; propagate
  through JobTemplatesController snapshot copy and GetTemplatesJson projection,
  and JobsController template-application path
- Test assertions updated for standardized BlobFileHelper error messages

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-09 22:12:33 -04:00
spouliot 61866e1d1e Add carried-over jobs section to Daily Board and fix tip visibility
Non-terminal jobs scheduled for past dates now appear in a red 'Carried
Over' section at the top of today's board so they can't silently disappear.
Also added alert-permanent to the board tip so the layout doesn't auto-dismiss it.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-09 15:34:39 -04:00
spouliot bc9de38da3 Fix Cash account subtype missing from debit-normal balance check
AccountSubType.Cash was not included in IsNormalDebitBalance in both
AccountBalanceService and LedgerService, causing Cash accounts to be
treated as credit-normal. Payments deposited to a Cash account were
debited in the wrong direction, producing a negative balance.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 23:13:24 -04:00
spouliot 2694863d07 Fix health check URL to use production custom domain
Jenkins agent cannot resolve linuxpcl.azurewebsites.net due to DNS
restrictions on the build machine.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 23:08:30 -04:00
spouliot 8646fa83c8 Fix PowerShell syntax error in zip creation step
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 22:56:12 -04:00
spouliot 796d084ea6 Fix zip entry paths to use forward slashes for Linux compatibility
ZipFile.CreateFromDirectory on Windows produces backslash paths in zip
entries. Linux treats backslash as a literal filename character so
wwwroot\css\site.css is never found at wwwroot/css/site.css. Build the
zip manually with Replace backslash→forward slash on each entry name.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 22:46:24 -04:00
spouliot 6d23c63912 Stop app before deploy to prevent rsync file lock failures
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 22:11:04 -04:00
spouliot 3803d16731 Fix prod Jenkins pipeline: add tests, restart, and health check stage
- Add Test stage (unit tests only) before migrations so bad code fails fast
- Add az webapp restart after async deploy to ensure new code is loaded
- Add Health Check stage that polls app for up to 3 minutes post-deploy
- Restore xcopy of wwwroot (needed for linux-x64 publish on Windows)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 21:44:51 -04:00
spouliot 29fd7163dc Merge dev into master: billing email, SMS consent, incoming powder, invoice/job fixes
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 21:13:54 -04:00
spouliot 9a52e7fae5 Ad-hoc quote email, accounting improvements, AI lookup fix, and misc service updates
- Quotes: ad-hoc email modal on Quote Details lets staff send to an address not on file;
  QuotesController passes overrideEmail through to NotificationService
- Quotes/Details view: SMS consent display, email/SMS send button state based on consent
- Accounting module: AccountingDisplayHelpers for consistent ledger formatting;
  AccountsController + Accounts views improvements; AccountingEnums additions
- Bills/Expenses: AI account categorization fixes in BillsController and ExpensesController
- InventoryAiLookupService: TDS cure fallback no longer fires on AiAugmentFromUrl path
  (LookupByUrlAsync already has it built in — was double-fetching)
- PdfService: quote/invoice PDF updates
- PricingCalculationService: minor pricing logic fix
- QuoteProfile: mapping updates for new quote fields
- ApplicationDbContextModelSnapshot: catches up to all 4 migrations in this branch

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 20:48:00 -04:00
spouliot 0d980e651a Add pricing breakdown and powder pre-fill to Job Details; surface voided invoice history
- Job Details: collapsible internal pricing breakdown card mirrors quote details breakdown
  (items subtotal, shop supplies, discount, rush fee, tax, total)
- Job Details: voided invoice history section shows previous invoices instead of hiding them
- Complete Job modal: pre-fills powder usage from QR-scanned / manually logged entries so
  staff don't double-log; consumes pre-logged credit per InventoryItemId before deducting net delta
- JobProfile: map ShopSuppliesAmount, ShopSuppliesPercent, IsRushJob, DiscountType/Value/Reason
  so the pricing breakdown has the data it needs

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 20:47:44 -04:00
spouliot 3278152d83 Fix invoice re-creation after void; add payment terms selector and shop supplies line
- Voided invoices no longer block creating a new invoice for the same job: voided invoice's
  JobId FK is cleared so the unique index slot is freed for the replacement
- Invoice Details view shows voided invoices as history rather than hiding them
- Payment terms: standardized SelectList (Due on Receipt, Net 15/30/45/60/90, 2% 10 Net 30,
  COD) with custom-term preservation; invoice-due-date.js auto-updates Due Date on term change
- Shop supplies on direct (no-quote) jobs: InvoicesController derives the shop supplies line
  from the company rate when the job has no source quote to read the pre-agreed amount from
- Job entity: ShopSuppliesAmount + ShopSuppliesPercent fields preserved through job lifecycle
- Migration: AddShopSuppliesAmountToJob

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 20:47:34 -04:00
spouliot fc35fd123c Add IsIncoming inventory flag and catalog-to-incoming powder flow in item wizard
- InventoryItem.IsIncoming: marks powder ordered but not yet received; enables QR code
  printing on work orders while the shipment is in transit
- InventoryController.CreateIncomingFromCatalog: POST endpoint creates a 0-balance inventory
  record from a PowderCatalogItem and returns it in wizard-compatible shape
- item-wizard.js: custom coat tab now searches the platform powder catalog as a fallback;
  catalog results show an 'Add as Incoming Order' option; createIncomingFromCatalog POSTs
  to server and selects the new item without a page refresh
- QuoteItemCoatDto: CatalogItemId + AddAsIncoming fields so the wizard can signal server-side
  incoming-item creation during quote save
- Inventory Create/Edit/Index views: IsIncoming badge and field
- IInventoryAiLookupService: minor interface update
- Migration: AddInventoryIsIncoming

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 20:47:19 -04:00
spouliot f40d58ac2e Add TCPA-compliant SMS consent tracking for prospect quotes
- Quote entity: ProspectSmsConsent (bool) + ProspectSmsConsentedAt (DateTime?) fields
- QuoteDtos: consent fields on Create/Update/Convert DTOs with TCPA guidance text
- Quote Create/Edit views: SMS consent checkbox shown when mobile number is entered
- Quote ConvertToCustomer view: staff must re-confirm consent carries over to customer record
- QuoteApproval: consent state exposed in ViewModel and ApprovalPage for transparency
- Consent timestamp cleared when prospect quote is linked to an existing customer
- Migration: AddProspectSmsConsent

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 20:47:04 -04:00
spouliot fb979bc88d Add BillingEmail field for commercial customers; support comma-separated multi-email
- Customer entity + DTO: new BillingEmail field (accounting/invoicing address)
- Email fields now accept comma-separated lists; DTO validates each address individually
- NotificationService: SendToEmailListAsync helper fans out to all addresses in a list;
  NotifyQuoteSentAsync accepts optional overrideEmail so staff can send to an ad-hoc address
- Migration: AddCustomerBillingEmail
- Customer Create/Edit/Details views updated to show Billing Email field
- customer-billing-email.js: client-side helpers for billing email input

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 20:46:53 -04:00
spouliot 12f784f34c Add Unit Price column to quote PDF
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-06 21:41:44 -04:00
spouliot 90f93b6e2f Fall back to ProspectEmail for CustomerEmail on prospect quotes
Prospect quotes have no CustomerId so Customer is null — email is stored
in ProspectEmail directly on the quote. The send-button visibility check
was always seeing null and showing the 'no contact info' warning.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-06 21:31:12 -04:00
spouliot 135fd6f8d7 Clarify no-contact warning to say 'mobile number' not 'phone'
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-06 21:06:24 -04:00
spouliot ff231d9dd2 Set quote status to Converted and show job number link on quote details
- CreateJobFromQuote now sets QuoteStatusId to CONVERTED after creating the job
- Added ConvertedToJobNumber to QuoteDto, populated in Details action
- 'View Job' button on Quote Details now shows the job number (e.g. 'View Job JOB-2505-0001')

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-06 20:47:45 -04:00
479 changed files with 373031 additions and 6480 deletions
+3 -1
View File
@@ -172,7 +172,9 @@
"Bash(Select-Object -First 20)",
"PowerShell(node -e \"require\\('fs'\\).existsSync\\(require\\('path'\\).join\\(process.cwd\\(\\), 'node_modules', 'sharp'\\)\\) ? console.log\\('sharp ok'\\) : console.log\\('no sharp'\\)\")",
"WebFetch(domain:www.powdercoatinglogix.com)",
"PowerShell($bytes = [System.IO.File]::ReadAllBytes\\('src/PowderCoating.Web/Views/Jobs/Details.cshtml'\\); $text = [System.Text.Encoding]::UTF8.GetString\\($bytes\\); $idx = $text.IndexOf\\('hasPowderData'\\); $snippet = $text.Substring\\($idx - 20, 250\\); [System.Text.Encoding]::Unicode.GetBytes\\($snippet\\) | Format-Hex | Select-Object -First 30)"
"PowerShell($bytes = [System.IO.File]::ReadAllBytes\\('src/PowderCoating.Web/Views/Jobs/Details.cshtml'\\); $text = [System.Text.Encoding]::UTF8.GetString\\($bytes\\); $idx = $text.IndexOf\\('hasPowderData'\\); $snippet = $text.Substring\\($idx - 20, 250\\); [System.Text.Encoding]::Unicode.GetBytes\\($snippet\\) | Format-Hex | Select-Object -First 30)",
"PowerShell($dll = \"C:\\\\Users\\\\spoul\\\\.nuget\\\\packages\\\\questpdf\\\\2024.12.3\\\\lib\\\\net6.0\\\\QuestPDF.dll\"; $asm = [Reflection.Assembly]::LoadFile\\($dll\\); $asm.GetTypes\\(\\) | Where-Object { $_.Name -eq \"ContainerExtensions\" } | ForEach-Object { $_.GetMethods\\(\\) | Where-Object { $_.Name -match \"Canvas|Rotat|Layer\" } | Select-Object Name } | Sort-Object Name -Unique)",
"PowerShell(Get-ChildItem \"C:\\\\Users\\\\spoul\\\\.nuget\\\\packages\\\\\" -ErrorAction SilentlyContinue | Where-Object { $_.Name -match \"quest|skia\" } | Select-Object Name)"
]
}
}
+4
View File
@@ -129,3 +129,7 @@ DataProtection-Keys/
# Secrets
appsettings.secrets.json
*.pfx
# Local task tracking
TODO.txt
TODO.txt.bak
+21
View File
@@ -478,6 +478,27 @@ All modules below are fully implemented with controllers, views, and migrations
- In-stock inventory powder: charge for calculated usage only (surface area × lbs/sqft × unit cost)
- Tax exempt customers (`Customer.IsTaxExempt`): `TaxPercent` defaults to 0 on quote and invoice create; customer dropdown marks exempt customers with ★
### Pricing Routing Flags — Must Stay In Sync Across All Three Layers
`PricingCalculationService.CalculateQuoteItemPriceAsync` routes each item to the correct pricing path using boolean flags. **These flags MUST exist identically on `QuoteItem`, `JobItem`, and `CreateQuoteItemDto`, AND be mapped in all three `JobItemAssemblyService.CreateJobItem` overloads.**
| Flag | Effect if missing on JobItem |
|------|------------------------------|
| `IsAiItem` | Job repriced as calculated item; oven cost double-charged on every save |
| `IsGenericItem` | ManualUnitPrice ignored; price recalculated from surface area |
| `IsLaborItem` | Item repriced at surface-area rate instead of hours × labor rate |
| `IsSalesItem` | ManualUnitPrice ignored; item repriced using coat/surface math |
**Checklist when adding a new pricing routing flag:**
1. Add the property to `QuoteItem` (Core/Entities)
2. Add the property to `JobItem` (Core/Entities)
3. Add it to `CreateQuoteItemDto` (Application/DTOs)
4. Add it to `JobItemSeed` (private class in JobItemAssemblyService)
5. Map it in all three `JobItemAssemblyService.CreateJobItem` overloads
6. Include it in every `existingItemsData` JSON block in job views (`Edit.cshtml`, `EditItems.cshtml`) and in all job controller actions that build `CreateQuoteItemDto` from a `JobItem`
7. Add a migration if the field is new on a persisted entity
8. The structural test `PricingRoutingFlags_ExistOnBothQuoteItemAndJobItem` in `JobItemAssemblyServiceTests` will fail until steps 13 are done — this is intentional
### Branding
- Application name: **Powder Coating Logix**
- PCL logo: `wwwroot/images/pcl-logo.png` — used in sidebar header (when no tenant logo), login/register pages, sidebar footer
Vendored
+49 -1
View File
@@ -24,6 +24,17 @@ pipeline {
}
}
stage('Test') {
steps {
bat 'dotnet test tests\\PowderCoating.UnitTests --no-build -c Release --logger "trx;LogFileName=results.trx" --results-directory TestResults'
}
post {
always {
junit testResults: 'TestResults/*.trx', allowEmptyResults: true
}
}
}
stage('Run Migrations') {
steps {
bat 'dotnet tool install --global dotnet-ef 2>nul || dotnet tool update --global dotnet-ef 2>nul'
@@ -42,7 +53,18 @@ pipeline {
stage('Deploy to Azure') {
steps {
bat 'powershell -Command "Add-Type -Assembly System.IO.Compression.FileSystem; if (Test-Path deploy.zip) { Remove-Item deploy.zip }; [System.IO.Compression.ZipFile]::CreateFromDirectory(\'publish\', \'deploy.zip\')"'
powershell '''
Add-Type -Assembly System.IO.Compression.FileSystem
if (Test-Path deploy.zip) { Remove-Item deploy.zip }
$publishDir = (Resolve-Path "publish").Path
$zip = [System.IO.Compression.ZipFile]::Open("deploy.zip", "Create")
Get-ChildItem -Path $publishDir -Recurse -File | ForEach-Object {
$entryName = $_.FullName.Substring($publishDir.Length + 1).Replace("\\", "/")
[System.IO.Compression.ZipFileExtensions]::CreateEntryFromFile($zip, $_.FullName, $entryName, "Optimal") | Out-Null
}
$zip.Dispose()
Write-Host "deploy.zip created with forward-slash entry paths"
'''
withCredentials([azureServicePrincipal(
credentialsId: 'azure-pcl',
subscriptionIdVariable: 'AZ_SUB_ID',
@@ -57,6 +79,32 @@ pipeline {
}
}
}
stage('Health Check') {
steps {
powershell '''
$url = "https://app.powdercoatinglogix.com/"
$timeout = 180
$elapsed = 0
Write-Host "Polling $url for up to $timeout seconds..."
do {
Start-Sleep -Seconds 10
$elapsed += 10
try {
$r = Invoke-WebRequest $url -UseBasicParsing -TimeoutSec 10
if ($r.StatusCode -lt 400) {
Write-Host "App responded HTTP $($r.StatusCode) after ${elapsed}s"
exit 0
}
} catch {
Write-Host "[${elapsed}s] Not yet responding: $_"
}
} while ($elapsed -lt $timeout)
Write-Error "App did not come healthy within $timeout seconds"
exit 1
'''
}
}
}
post {
-199
View File
@@ -1,199 +0,0 @@
Shop Management App TO DO List
==============================
-Inventory Lookup not always finding price for Columbia Coatings
-Logging powder usage and choosing a job doesn't record properly in the activity section of the powder itself
-Need to allow deleting of powder usage entries, or at least editing in case of a goof up
-Still random weird characters on a bunch of pages. Intake button for example on the jobs screen shows: Intake ✓
-Google review request email after a job
-Check my ChatGPT chat about surface area for a few solid ideas for the system
-Fix up approve/decline messages between customer and user on quote approval feature
Done and need testing
=====================
-Add sorting to all grids
-Add searching to all grids
-Add Workers to the system
-Allow jobs to be assigned to workers
-Add Shop Job Board display to show in the shop
-Added quick edits on a few pages
-Fix job page customer drop down. It's only showing business names and not individuals
-Add country drop down on customer edit and add pages
-Conver customer once quote accepted not complete
-Add Dashboard page
-Low Inventory Warnings display
-Overdue jobs
-Todays Jobs
-new quote button on customer page doesnt pre-select customer
-Add customer job history page
-Profiles can now change from a light theme to a dark theme as well as other appearance changes
-Date format can be customized per profile
-Timezone can now be changed per profile
-Have company logos stored in the database with the other company information
-Add Company Name under Logo in navbar
-Make logo bigger
-Update create quote page to show names of individual customers or company name depending on which type it is
-Validate that the company has entered operating costs before allowing the quote page to be loaded
-Make phone number and contact required on quotes for new prospects
-Move the create quote button to the right side of the screen to be consistent with other pages
-Add setting for tax exempt on customer
-Added tax certificate upload as well
-Add shop minimum to quoting system and company settings
-Add Rush Job Fee (customizable in company settings)
-Add ability to quick change the status on the job listing and record who changed the status.
-Deactivating company should NOT allow any users to login at all.
-Allow superadmins to create company users/managers
-Add a print quote button
-Add a download PDF button for quotes
-When adding users, also create worker records
-Add quick update to all view pages
-Add Mobile layouts
-Fix a few text pieces on the dashboard page that did not invert properly when dark mode was selected
-Add ability to upload job photos
-Allow photo uploads for jobs before and after photos
-Added Log Viewer
-Added Seed Data option for super admins that will assist during testing
-Add an item list with prices for repeat parts and such
-Add manual data seeding that super admins can use to seed a company one at a time if needed
-Add Log Viewer for Super Admins
-Quotes cleaned up quite a bit and calculations and style changed
-Approving a Quote will now auto-create a Job and link back to the quote it came from.
-Job Items now appear on the Job Screen with the line items from the quote
-Job items can be edited
-Add a way to convert a quote into a job
-Add multiple item types to add to a quote
1. Pre-Defined item that we can choose from our product list
2. Batch items where we enter the square footage manually as well as the quantity
-Add Quickbooks import for customers and price lists (Desktop and Online)
-Custom Order Powder not saving or displaying properly on quuote page
-Added ability for Companies to define their own Job Status, Job Priority, and Quote Status' via Company Settings > Data Lookups
-Add Randomizer Wheel
-Add Quickbooks format export for
-Customers
-Product Catalog
-Invoices
-Quote for Product Catalog Item is only selecting items from Powder Coating, need all items
-Add a Shop Supplies operating cost that will be used on quote calculations
-Fix Quote screen, only Powder showing in item dropdown. Need to get all items in an IsCoating category showing up.
-Update everywhere that uses tax rate to read and use this setting
-Add ability to export a full price list for known items
-Add tracking for all changes and show change history on view page. Possibly in a hidden grid or modal
-Update the inventory screen to not duplicate color name fields and the like
-Add option for metric system
-Add Bulk Upload for
-Powder
-Product Catalog
-Customer Data
-Add an Appointment engine and Calendar. Also show Maintenence tasks that are scheduled on it
-Allow shops to put employee days off on the calendar as well
-Fix and Verify user permissions are honored
-Run a full security check on the application
-Add support for multi stage coatings on an item
-Fix Seed Data routines to track errors better and continue past error imports
-Add ability to complete a job and enter actual time and materials used
-Add export for all data to CSV format
-Check calendar resizing with the browser. It's off a bit
-Add ability to apply discounts
-Remove powder from inventory when completeing a job
-Add color change ability for appointment types
-Add code to honor the rush charge on a quote
-Add options to quote for Sandblasting, Masking, Chemical Strip, Outgas, Phosphate Wash, Degrease
-Add ability to add sq ft to product catalog item for powder estimation
-Add better UX design for validation errors and such
Option 1: Change "ModelOnly" to "All" (1 line change) - Shows all validation errors at top of form in red alert box
- User would have seen: "The field Estimated Minutes must be between 0 and 10,000"
Option 2: Add inline validation (more complex)
- Show error messages right next to the problematic field
- Better UX but requires adding validation spans to dynamic fields
Option 3: Toast notifications (requires new library/code)
- Modern popup notifications for success/error messages
- Would need to add a toast library (like Toastr) and wire it up
-Add Import/Export for Company Settings
-Allow Super Admin to modify permissions for company admins in case we add any in the future, or if anything gets messed up we can fix it!
-Allow recurring scheduled maintenance
-Let's show scheduled maintenence on the job schedule as well. At the top of the screen
-Make sure maintenence shows on the calendar list view.
-Add viewing quotes on the customer details page so we can see all quotes/jobs for a given customer to make things easier to find.
-Add support for multiple ovens in operating costs
-Display oven selected on quote and job detail pages
-Allow user to choose an oven on a quote, and have it follow through to a job
-Check for any old and outdated code and DB fields!
-Add ability to email a quote
-Add email capabilities
-Add search on super admin companies screen
-Set limits on job photos per app tier
-Check subscription signup page to make sure the selected subscription is actually saved.
-Don't seed the product catalog on a new user
-Check to make sure subscription page has quotes and all fields on it
-Allow customizing of the quote sheets and invoices (If we do them)
-Add feature to allow username changes
-Fix quickbooks imports based on files Colton sent
-Add thicker border around input fields to signify they are text boxes
-Check to make sure emails get sent when a quote is created
-Add buttons to send emails manually if needed
-Modify price calculations to prompt for service times (ie... sandblasting, oven cure times, outgas times etc)
-Add ability to modify items on jobs
-Swap quoting page to use modals to add items to segregate it a bit better.
-Build account ledger/transaction summary view
-Add security for financial pages
-Allow opening balances for accounts
-Create P&L and other reports
-Allow receipet upload on expenses and bills
-Download PDF for invoices throws and error
-Emailing invoice doesn't seem to trigger
-When a customer record has email notifications turned off, disable any email buttons that may send one and alert the user that this customer is set to have notifications turned off.
-When doing anything that sends mail, prompt the user to alert them a message will be sent
-Create a setup wizard for new users that will walk through system setup. Allow re-running later.
-Check Workflow steps in wizard, might need adjusting
-Account Summary, use permanent alert for info message at bottom
-Add steps so that the new user can customize the data lookups and re-order them
-Reorder menu to work better
-Add ability to print a job invoice once completed
-Add ability to email a job invoice
-Integrate invoicing/billing/reports
-Add customer portal to approve quotes from a link for now. We can do a full login later.
-Need a complexity score for quoting parts (Simple, moderate, complex, extreme)
-Add tagging options for quotes and jobs (user driven)
-Can we also add this tag system to quotes and jobs to allow users to tag themselves and we can use that data later as well? We'd have to add a good
description of WHY the user should add some tags though.
-Inventory forecasting might be worth looking into
-Build some AI powder usage predictions into the system
-AI Production Scheduling - Batching enough parts together to fill the oven automagically
-Update dashboard to show some $$$ fields
-Update Setup Wizard
-Update the Setup Checklist
-Modify system to keep running balances of all accounts
- Make sure ALL job updates refresh the Shop Display
-Add multiple item types to add to a quote
AI Agent item where we upload a picture and it will calculate the approximate sq ft and quote from that
-Integration with stripe or square to accept online paymens from our users customers.
-AI Assistant for help
-Allow customer filtering on quotes and jobs
-New job page blanks when validation fails
-Can we keep track of which users have completed the setup wizard?
-Make sure we're tracking logins. I see a user logged on, but the company health page states they have never logged in.
-Allow printing blank work orders (model after the SCP Powder Coating blank work order)
-IDEA: Print powders to use on work order with their QR code so they can be scanned right from there and usage recorded.
-Add ability to save a quoted item to the product catalog either from an AI Photo Quote or from the calculated item
-Add images to product catalog items for easily identification of parts
-Look into possibly having AI scan a product catalog and suggest prices for items.
-Add Oven and Add Blasting Setup don't work in Setup Wizard
-When scanning inventory QR Code, there is no cancel button
-Bug: When scanning Inventory QR Code, if not logged in...it takes you to the dashboard after login, not our inventory scanning screen
-Add SMS capabilities
-Lookup not working 100% correct. If I type columbia as the manufacturer and a color name....it's finding blackmamba from prismatic incorrectly.
-Lookup Modal not showing ALL matches. Maybe make scrollable
-Pickup cure information from TDS Sheet if not found by AI Search
-ON AI Photo Quote page, when the AI info comes back we should scroll the modal window down so it's visible. It's not clear that new info has been added to the modal for all customers
Ideas Removed
=======================
-Add Deactivate Customer button on Customer Detail page
Logins:
rich@r2r.com/Ragz2Richs123!
rich@cannon.com/Cannon123!
-197
View File
@@ -1,197 +0,0 @@
Shop Management App TO DO List
==============================
-Inventory Lookup not always finding price for Columbia Coatings
-Logging powder usage and choosing a job doesn't record properly in the activity section of the powder itself
-Need to allow deleting of powder usage entries, or at least editing in case of a goof up
-Google review request email after a job
-Check my ChatGPT chat about surface area for a few solid ideas for the system
-Fix up approve/decline messages between customer and user on quote approval feature
Done and need testing
=====================
-Add sorting to all grids
-Add searching to all grids
-Add Workers to the system
-Allow jobs to be assigned to workers
-Add Shop Job Board display to show in the shop
-Added quick edits on a few pages
-Fix job page customer drop down. It's only showing business names and not individuals
-Add country drop down on customer edit and add pages
-Conver customer once quote accepted not complete
-Add Dashboard page
-Low Inventory Warnings display
-Overdue jobs
-Todays Jobs
-new quote button on customer page doesnt pre-select customer
-Add customer job history page
-Profiles can now change from a light theme to a dark theme as well as other appearance changes
-Date format can be customized per profile
-Timezone can now be changed per profile
-Have company logos stored in the database with the other company information
-Add Company Name under Logo in navbar
-Make logo bigger
-Update create quote page to show names of individual customers or company name depending on which type it is
-Validate that the company has entered operating costs before allowing the quote page to be loaded
-Make phone number and contact required on quotes for new prospects
-Move the create quote button to the right side of the screen to be consistent with other pages
-Add setting for tax exempt on customer
-Added tax certificate upload as well
-Add shop minimum to quoting system and company settings
-Add Rush Job Fee (customizable in company settings)
-Add ability to quick change the status on the job listing and record who changed the status.
-Deactivating company should NOT allow any users to login at all.
-Allow superadmins to create company users/managers
-Add a print quote button
-Add a download PDF button for quotes
-When adding users, also create worker records
-Add quick update to all view pages
-Add Mobile layouts
-Fix a few text pieces on the dashboard page that did not invert properly when dark mode was selected
-Add ability to upload job photos
-Allow photo uploads for jobs before and after photos
-Added Log Viewer
-Added Seed Data option for super admins that will assist during testing
-Add an item list with prices for repeat parts and such
-Add manual data seeding that super admins can use to seed a company one at a time if needed
-Add Log Viewer for Super Admins
-Quotes cleaned up quite a bit and calculations and style changed
-Approving a Quote will now auto-create a Job and link back to the quote it came from.
-Job Items now appear on the Job Screen with the line items from the quote
-Job items can be edited
-Add a way to convert a quote into a job
-Add multiple item types to add to a quote
1. Pre-Defined item that we can choose from our product list
2. Batch items where we enter the square footage manually as well as the quantity
-Add Quickbooks import for customers and price lists (Desktop and Online)
-Custom Order Powder not saving or displaying properly on quuote page
-Added ability for Companies to define their own Job Status, Job Priority, and Quote Status' via Company Settings > Data Lookups
-Add Randomizer Wheel
-Add Quickbooks format export for
-Customers
-Product Catalog
-Invoices
-Quote for Product Catalog Item is only selecting items from Powder Coating, need all items
-Add a Shop Supplies operating cost that will be used on quote calculations
-Fix Quote screen, only Powder showing in item dropdown. Need to get all items in an IsCoating category showing up.
-Update everywhere that uses tax rate to read and use this setting
-Add ability to export a full price list for known items
-Add tracking for all changes and show change history on view page. Possibly in a hidden grid or modal
-Update the inventory screen to not duplicate color name fields and the like
-Add option for metric system
-Add Bulk Upload for
-Powder
-Product Catalog
-Customer Data
-Add an Appointment engine and Calendar. Also show Maintenence tasks that are scheduled on it
-Allow shops to put employee days off on the calendar as well
-Fix and Verify user permissions are honored
-Run a full security check on the application
-Add support for multi stage coatings on an item
-Fix Seed Data routines to track errors better and continue past error imports
-Add ability to complete a job and enter actual time and materials used
-Add export for all data to CSV format
-Check calendar resizing with the browser. It's off a bit
-Add ability to apply discounts
-Remove powder from inventory when completeing a job
-Add color change ability for appointment types
-Add code to honor the rush charge on a quote
-Add options to quote for Sandblasting, Masking, Chemical Strip, Outgas, Phosphate Wash, Degrease
-Add ability to add sq ft to product catalog item for powder estimation
-Add better UX design for validation errors and such
Option 1: Change "ModelOnly" to "All" (1 line change) - Shows all validation errors at top of form in red alert box
- User would have seen: "The field Estimated Minutes must be between 0 and 10,000"
Option 2: Add inline validation (more complex)
- Show error messages right next to the problematic field
- Better UX but requires adding validation spans to dynamic fields
Option 3: Toast notifications (requires new library/code)
- Modern popup notifications for success/error messages
- Would need to add a toast library (like Toastr) and wire it up
-Add Import/Export for Company Settings
-Allow Super Admin to modify permissions for company admins in case we add any in the future, or if anything gets messed up we can fix it!
-Allow recurring scheduled maintenance
-Let's show scheduled maintenence on the job schedule as well. At the top of the screen
-Make sure maintenence shows on the calendar list view.
-Add viewing quotes on the customer details page so we can see all quotes/jobs for a given customer to make things easier to find.
-Add support for multiple ovens in operating costs
-Display oven selected on quote and job detail pages
-Allow user to choose an oven on a quote, and have it follow through to a job
-Check for any old and outdated code and DB fields!
-Add ability to email a quote
-Add email capabilities
-Add search on super admin companies screen
-Set limits on job photos per app tier
-Check subscription signup page to make sure the selected subscription is actually saved.
-Don't seed the product catalog on a new user
-Check to make sure subscription page has quotes and all fields on it
-Allow customizing of the quote sheets and invoices (If we do them)
-Add feature to allow username changes
-Fix quickbooks imports based on files Colton sent
-Add thicker border around input fields to signify they are text boxes
-Check to make sure emails get sent when a quote is created
-Add buttons to send emails manually if needed
-Modify price calculations to prompt for service times (ie... sandblasting, oven cure times, outgas times etc)
-Add ability to modify items on jobs
-Swap quoting page to use modals to add items to segregate it a bit better.
-Build account ledger/transaction summary view
-Add security for financial pages
-Allow opening balances for accounts
-Create P&L and other reports
-Allow receipet upload on expenses and bills
-Download PDF for invoices throws and error
-Emailing invoice doesn't seem to trigger
-When a customer record has email notifications turned off, disable any email buttons that may send one and alert the user that this customer is set to have notifications turned off.
-When doing anything that sends mail, prompt the user to alert them a message will be sent
-Create a setup wizard for new users that will walk through system setup. Allow re-running later.
-Check Workflow steps in wizard, might need adjusting
-Account Summary, use permanent alert for info message at bottom
-Add steps so that the new user can customize the data lookups and re-order them
-Reorder menu to work better
-Add ability to print a job invoice once completed
-Add ability to email a job invoice
-Integrate invoicing/billing/reports
-Add customer portal to approve quotes from a link for now. We can do a full login later.
-Need a complexity score for quoting parts (Simple, moderate, complex, extreme)
-Add tagging options for quotes and jobs (user driven)
-Can we also add this tag system to quotes and jobs to allow users to tag themselves and we can use that data later as well? We'd have to add a good
description of WHY the user should add some tags though.
-Inventory forecasting might be worth looking into
-Build some AI powder usage predictions into the system
-AI Production Scheduling - Batching enough parts together to fill the oven automagically
-Update dashboard to show some $$$ fields
-Update Setup Wizard
-Update the Setup Checklist
-Modify system to keep running balances of all accounts
- Make sure ALL job updates refresh the Shop Display
-Add multiple item types to add to a quote
AI Agent item where we upload a picture and it will calculate the approximate sq ft and quote from that
-Integration with stripe or square to accept online paymens from our users customers.
-AI Assistant for help
-Allow customer filtering on quotes and jobs
-New job page blanks when validation fails
-Can we keep track of which users have completed the setup wizard?
-Make sure we're tracking logins. I see a user logged on, but the company health page states they have never logged in.
-Allow printing blank work orders (model after the SCP Powder Coating blank work order)
-IDEA: Print powders to use on work order with their QR code so they can be scanned right from there and usage recorded.
-Add ability to save a quoted item to the product catalog either from an AI Photo Quote or from the calculated item
-Add images to product catalog items for easily identification of parts
-Look into possibly having AI scan a product catalog and suggest prices for items.
-Add Oven and Add Blasting Setup don't work in Setup Wizard
-When scanning inventory QR Code, there is no cancel button
-Bug: When scanning Inventory QR Code, if not logged in...it takes you to the dashboard after login, not our inventory scanning screen
-Add SMS capabilities
-Lookup not working 100% correct. If I type columbia as the manufacturer and a color name....it's finding blackmamba from prismatic incorrectly.
-Lookup Modal not showing ALL matches. Maybe make scrollable
-Pickup cure information from TDS Sheet if not found by AI Search
-ON AI Photo Quote page, when the AI info comes back we should scroll the modal window down so it's visible. It's not clear that new info has been added to the modal for all customers
Ideas Removed
=======================
-Add Deactivate Customer button on Customer Detail page
Logins:
rich@r2r.com/Ragz2Richs123!
rich@cannon.com/Cannon123!
@@ -322,3 +322,214 @@ public class ClaudeAnomalyFlag
public string? RecommendedAction { get; set; }
public string? BillNumber { get; set; }
}
// ── Feature 7: Bank Rec Auto-Match ───────────────────────────────────────────
public class BankRecMatchItem
{
public string EntityType { get; set; } = string.Empty; // "Payment", "BillPayment", "Expense"
public int EntityId { get; set; }
public string Date { get; set; } = string.Empty; // ISO 8601
public string Reference { get; set; } = string.Empty;
public string Description { get; set; } = string.Empty;
public decimal Amount { get; set; }
public string Direction { get; set; } = string.Empty; // "deposit" or "payment"
}
public class AutoMatchRequest
{
public List<BankRecMatchItem> UnclearedItems { get; set; } = new();
public decimal BeginningBalance { get; set; }
public decimal StatementEndingBalance { get; set; }
}
public class AutoMatchSuggestion
{
public string EntityType { get; set; } = string.Empty;
public int EntityId { get; set; }
public double Confidence { get; set; } // 0.01.0
public string Reason { get; set; } = string.Empty;
}
public class AutoMatchResult
{
public bool Success { get; set; }
public string? ErrorMessage { get; set; }
public List<AutoMatchSuggestion> SuggestedCleared { get; set; } = new();
public List<string> Insights { get; set; } = new();
}
/// <summary>Internal JSON schema that Claude returns for bank rec auto-match.</summary>
public class ClaudeAutoMatchResponse
{
public List<ClaudeAutoMatchSuggestion> SuggestedCleared { get; set; } = new();
public List<string> Insights { get; set; } = new();
}
public class ClaudeAutoMatchSuggestion
{
public string EntityType { get; set; } = string.Empty;
public int EntityId { get; set; }
public double Confidence { get; set; }
public string Reason { get; set; } = string.Empty;
}
// ── Feature 8: Late Payment Prediction ───────────────────────────────────────
public class OpenInvoiceSummary
{
public string InvoiceNumber { get; set; } = string.Empty;
public decimal BalanceDue { get; set; }
public string? DueDateIso { get; set; }
public int DaysOverdue { get; set; }
}
public class LatePaymentCustomerData
{
public string CustomerName { get; set; } = string.Empty;
public decimal TotalOwed { get; set; }
public double AvgDaysToPay { get; set; } // historical average
public int TotalInvoicesAllTime { get; set; }
public int LateInvoicesAllTime { get; set; }
public List<OpenInvoiceSummary> OpenInvoices { get; set; } = new();
}
public class LatePaymentPredictionRequest
{
public string CompanyName { get; set; } = string.Empty;
public List<LatePaymentCustomerData> Customers { get; set; } = new();
}
public class LatePaymentPrediction
{
public string CustomerName { get; set; } = string.Empty;
/// <summary>"high", "medium", or "low"</summary>
public string RiskLevel { get; set; } = "medium";
public int EstimatedDaysToPayment { get; set; }
public string Reasoning { get; set; } = string.Empty;
}
public class LatePaymentPredictionResult
{
public bool Success { get; set; }
public string? ErrorMessage { get; set; }
public List<LatePaymentPrediction> Predictions { get; set; } = new();
public List<string> Insights { get; set; } = new();
}
/// <summary>Internal JSON schema that Claude returns for late payment predictions.</summary>
public class ClaudeLatePaymentResponse
{
public List<ClaudeLatePaymentPrediction> Predictions { get; set; } = new();
public List<string> Insights { get; set; } = new();
}
public class ClaudeLatePaymentPrediction
{
public string CustomerName { get; set; } = string.Empty;
public string RiskLevel { get; set; } = "medium";
public int EstimatedDaysToPayment { get; set; }
public string Reasoning { get; set; } = string.Empty;
}
// ── Feature 9: Natural Language Financial Queries ─────────────────────────────
public class MonthlyFinancialSummary
{
public string Month { get; set; } = string.Empty; // "YYYY-MM"
public decimal Revenue { get; set; }
public decimal Expenses { get; set; }
public decimal NetIncome { get; set; }
}
public class FinancialQueryContext
{
public string CompanyName { get; set; } = string.Empty;
public string AsOfDate { get; set; } = string.Empty;
public decimal TotalRevenueYtd { get; set; }
public decimal TotalExpensesYtd { get; set; }
public decimal NetIncomeYtd { get; set; }
public decimal ArOutstanding { get; set; }
public decimal ApOutstanding { get; set; }
public List<MonthlyFinancialSummary> Last12Months { get; set; } = new();
public List<ExpenseByCategory> ExpensesByCategory { get; set; } = new();
}
public class FinancialQueryRequest
{
public string Question { get; set; } = string.Empty;
public FinancialQueryContext Context { get; set; } = new();
}
public class FinancialQueryResult
{
public bool Success { get; set; }
public string? ErrorMessage { get; set; }
public string Answer { get; set; } = string.Empty;
public string? FollowUpSuggestion { get; set; }
public List<string> RelevantFacts { get; set; } = new();
}
/// <summary>Internal JSON schema that Claude returns for financial queries.</summary>
public class ClaudeFinancialQueryResponse
{
public string Answer { get; set; } = string.Empty;
public string? FollowUpSuggestion { get; set; }
public List<string> RelevantFacts { get; set; } = new();
}
// ── Feature 10: Recurring Bill Detection ─────────────────────────────────────
public class RecurringBillHistoryItem
{
public string VendorName { get; set; } = string.Empty;
public string BillNumber { get; set; } = string.Empty;
public decimal Amount { get; set; }
public string DateIso { get; set; } = string.Empty;
public string? Memo { get; set; }
}
public class RecurringBillDetectionRequest
{
public string CompanyName { get; set; } = string.Empty;
public List<RecurringBillHistoryItem> Bills { get; set; } = new();
}
public class RecurringBillPattern
{
public string VendorName { get; set; } = string.Empty;
/// <summary>"monthly", "quarterly", "biannual", "annual"</summary>
public string Frequency { get; set; } = string.Empty;
public decimal TypicalAmount { get; set; }
public string? NextExpectedDateIso { get; set; }
/// <summary>"high", "medium", or "low"</summary>
public string Confidence { get; set; } = "medium";
public string Description { get; set; } = string.Empty;
public string? SuggestedAction { get; set; }
}
public class RecurringBillDetectionResult
{
public bool Success { get; set; }
public string? ErrorMessage { get; set; }
public List<RecurringBillPattern> Patterns { get; set; } = new();
public List<string> Insights { get; set; } = new();
}
/// <summary>Internal JSON schema that Claude returns for recurring bill detection.</summary>
public class ClaudeRecurringBillResponse
{
public List<ClaudeRecurringPattern> Patterns { get; set; } = new();
public List<string> Insights { get; set; } = new();
}
public class ClaudeRecurringPattern
{
public string VendorName { get; set; } = string.Empty;
public string Frequency { get; set; } = string.Empty;
public decimal TypicalAmount { get; set; }
public string? NextExpectedDateIso { get; set; }
public string Confidence { get; set; } = "medium";
public string Description { get; set; } = string.Empty;
public string? SuggestedAction { get; set; }
}
@@ -2,6 +2,158 @@ using PowderCoating.Core.Enums;
namespace PowderCoating.Application.DTOs.Accounting;
// Accounting method badge — set on report DTOs so views can show "Cash Basis" / "Accrual Basis"
// without needing a separate round-trip to the company settings.
// ── Cash Flow Statement ──────────────────────────────────────────────────────
/// <summary>
/// Cash Flow Statement using the direct (cash-basis) method for operating activities.
/// Investing and Financing sections contain line items derived from account-level changes.
/// BeginningCash + NetChangeInCash should equal EndingCash (within rounding tolerances).
/// </summary>
public class CashFlowStatementDto
{
public string CompanyName { get; set; } = string.Empty;
public DateTime From { get; set; }
public DateTime To { get; set; }
public AccountingMethod Method { get; set; }
// ── Operating (direct / cash method) ───────────────────────────────────
/// <summary>Customer invoice payments received in the period.</summary>
public decimal CashFromCustomers { get; set; }
/// <summary>Vendor bill payments made in the period.</summary>
public decimal CashToVendors { get; set; }
/// <summary>Direct expense payments made in the period (not via bills).</summary>
public decimal CashForExpenses { get; set; }
public decimal NetOperating => CashFromCustomers - CashToVendors - CashForExpenses;
// ── Investing ──────────────────────────────────────────────────────────
public List<CashFlowLineDto> InvestingLines { get; set; } = new();
public decimal NetInvesting => InvestingLines.Sum(l => l.Amount);
// ── Financing ──────────────────────────────────────────────────────────
public List<CashFlowLineDto> FinancingLines { get; set; } = new();
public decimal NetFinancing => FinancingLines.Sum(l => l.Amount);
// ── Summary ────────────────────────────────────────────────────────────
public decimal BeginningCash { get; set; }
public decimal NetChangeInCash => NetOperating + NetInvesting + NetFinancing;
public decimal EndingCash => BeginningCash + NetChangeInCash;
}
/// <summary>A single line in the Investing or Financing section of the Cash Flow Statement.</summary>
public class CashFlowLineDto
{
public string Label { get; set; } = string.Empty;
/// <summary>Positive = cash inflow, negative = cash outflow.</summary>
public decimal Amount { get; set; }
}
// ── Customer / Vendor Statements ─────────────────────────────────────────────
public class CustomerStatementDto
{
public int CustomerId { get; set; }
public string CustomerName { get; set; } = string.Empty;
public string CompanyName { get; set; } = string.Empty;
public string? CustomerAddress { get; set; }
public DateTime From { get; set; }
public DateTime To { get; set; }
public decimal OpeningBalance { get; set; }
public List<StatementLineDto> Lines { get; set; } = new();
public decimal ClosingBalance { get; set; }
}
public class VendorStatementDto
{
public int VendorId { get; set; }
public string VendorName { get; set; } = string.Empty;
public string CompanyName { get; set; } = string.Empty;
public DateTime From { get; set; }
public DateTime To { get; set; }
public decimal OpeningBalance { get; set; }
public List<StatementLineDto> Lines { get; set; } = new();
public decimal ClosingBalance { get; set; }
}
public class StatementLineDto
{
public DateTime Date { get; set; }
/// <summary>E.g., "Invoice", "Payment", "Credit Applied", "Deposit Applied".</summary>
public string Type { get; set; } = string.Empty;
public string Reference { get; set; } = string.Empty;
public string Description { get; set; } = string.Empty;
/// <summary>Amount added to the balance (invoice for customer, bill for vendor).</summary>
public decimal? Debit { get; set; }
/// <summary>Amount reducing the balance (payment, credit).</summary>
public decimal? Credit { get; set; }
public decimal RunningBalance { get; set; }
}
// ── AP Aging ──────────────────────────────────────────────────────────────────
public class ApAgingReportDto
{
public DateTime AsOf { get; set; }
public string CompanyName { get; set; } = string.Empty;
public List<ApAgingVendorDto> Vendors { get; set; } = new();
public decimal TotalCurrent { get; set; }
public decimal Total1to30 { get; set; }
public decimal Total31to60 { get; set; }
public decimal Total61to90 { get; set; }
public decimal TotalOver90 { get; set; }
public decimal TotalOutstanding => TotalCurrent + Total1to30 + Total31to60 + Total61to90 + TotalOver90;
}
public class ApAgingVendorDto
{
public int VendorId { get; set; }
public string VendorName { get; set; } = string.Empty;
public List<ApAgingBillDto> Bills { get; set; } = new();
public decimal TotalCurrent { get; set; }
public decimal Total1to30 { get; set; }
public decimal Total31to60 { get; set; }
public decimal Total61to90 { get; set; }
public decimal TotalOver90 { get; set; }
public decimal TotalBalance => TotalCurrent + Total1to30 + Total31to60 + Total61to90 + TotalOver90;
}
public class ApAgingBillDto
{
public int BillId { get; set; }
public string BillNumber { get; set; } = string.Empty;
public DateTime BillDate { get; set; }
public DateTime? DueDate { get; set; }
public decimal BalanceDue { get; set; }
public int DaysOverdue { get; set; }
}
// ── Trial Balance ─────────────────────────────────────────────────────────────
public class TrialBalanceDto
{
public DateTime AsOf { get; set; }
public string CompanyName { get; set; } = string.Empty;
public List<TrialBalanceLine> Lines { get; set; } = new();
public decimal TotalDebits { get; set; }
public decimal TotalCredits { get; set; }
public bool IsBalanced => Math.Abs(TotalDebits - TotalCredits) < 0.01m;
}
public class TrialBalanceLine
{
public int AccountId { get; set; }
public string AccountNumber { get; set; } = string.Empty;
public string AccountName { get; set; } = string.Empty;
public AccountType AccountType { get; set; }
public decimal DebitBalance { get; set; }
public decimal CreditBalance { get; set; }
}
// ── Profit & Loss ─────────────────────────────────────────────────────────────
public class ProfitAndLossDto
@@ -9,6 +161,7 @@ public class ProfitAndLossDto
public DateTime From { get; set; }
public DateTime To { get; set; }
public string CompanyName { get; set; } = string.Empty;
public AccountingMethod AccountingMethod { get; set; } = AccountingMethod.Accrual;
public List<FinancialReportLine> RevenueLines { get; set; } = new();
public decimal TotalRevenue { get; set; }
@@ -40,6 +193,7 @@ public class BalanceSheetDto
{
public DateTime AsOf { get; set; }
public string CompanyName { get; set; } = string.Empty;
public AccountingMethod AccountingMethod { get; set; } = AccountingMethod.Accrual;
// Assets
public List<FinancialReportLine> CurrentAssets { get; set; } = new();
@@ -3,6 +3,22 @@ namespace PowderCoating.Application.DTOs.Common;
public class PagedResult<T>
{
public IEnumerable<T> Items { get; set; } = new List<T>();
/// <summary>
/// Creates a PagedResult populated from a GridRequest, avoiding repetitive property
/// assignments across every Index action. SortColumn, SortDirection, and SearchTerm
/// are copied from the grid so the model carries full state for view binding.
/// </summary>
public static PagedResult<T> From(GridRequest grid, IEnumerable<T> items, int totalCount) => new()
{
Items = items,
PageNumber = grid.PageNumber,
PageSize = grid.PageSize,
TotalCount = totalCount,
SortColumn = grid.SortColumn,
SortDirection = grid.SortDirection,
SearchTerm = grid.SearchTerm
};
public int PageNumber { get; set; }
public int PageSize { get; set; }
public int TotalCount { get; set; }
@@ -71,6 +71,11 @@ public class CompanyListDto
public bool WizardCompleted { get; set; }
public DateTime? WizardCompletedAt { get; set; }
public string? WizardCompletedByName { get; set; }
// Health signals — populated by CompaniesController.Index after the count summary query
public int HealthScore { get; set; }
public string HealthRisk { get; set; } = "Healthy";
public DateTime? LastLoginDate { get; set; }
}
/// <summary>
@@ -59,6 +59,9 @@ public class CompanyPreferencesDto
// Blank Work Order PDF Template
public string WoAccentColor { get; set; } = "#374151";
public string? WoTerms { get; set; }
// Kiosk settings
public string KioskIntakeOutput { get; set; } = "Quote";
}
public class UpdateAppDefaultsDto
@@ -136,3 +139,11 @@ public class UpdateWorkOrderTemplateDto
public string WoAccentColor { get; set; } = "#374151";
[StringLength(2000)] public string? WoTerms { get; set; }
}
public class UpdateKioskSettingsDto
{
/// <summary>"Quote" (default) or "Job" — what the kiosk creates on submission.</summary>
[Required]
public string KioskIntakeOutput { get; set; } = "Quote";
}
@@ -21,6 +21,7 @@ namespace PowderCoating.Application.DTOs.Company
public string? State { get; set; }
public string? ZipCode { get; set; }
public string? TimeZone { get; set; }
public AccountingMethod AccountingMethod { get; set; } = AccountingMethod.Accrual;
public bool HasLogo { get; set; }
public CompanyOperatingCostsDto? OperatingCosts { get; set; }
@@ -96,6 +97,9 @@ namespace PowderCoating.Application.DTOs.Company
[StringLength(50, ErrorMessage = "Time zone cannot exceed 50 characters")]
public string? TimeZone { get; set; }
/// <summary>Cash or Accrual accounting method preference for financial reports.</summary>
public AccountingMethod AccountingMethod { get; set; } = AccountingMethod.Accrual;
}
/// <summary>
@@ -108,6 +112,7 @@ namespace PowderCoating.Application.DTOs.Company
// Labor Rates
public decimal StandardLaborRate { get; set; }
public decimal? LaborCostPerHour { get; set; }
public decimal AdditionalCoatLaborPercent { get; set; }
// Equipment Operating Costs
@@ -181,6 +186,10 @@ namespace PowderCoating.Application.DTOs.Company
[Display(Name = "Standard Labor Rate ($/hr)")]
public decimal StandardLaborRate { get; set; }
[Range(0, 10000, ErrorMessage = "Labor cost rate must be between 0 and 10,000")]
[Display(Name = "Shop Labor Cost Rate ($/hr)")]
public decimal? LaborCostPerHour { get; set; }
[Range(0, 100, ErrorMessage = "Additional coat labor percent must be between 0 and 100")]
[Display(Name = "Additional Coat Labor (%)")]
public decimal AdditionalCoatLaborPercent { get; set; } = 30m;
@@ -9,6 +9,7 @@ public class CustomerDto
public string? ContactFirstName { get; set; }
public string? ContactLastName { get; set; }
public string? Email { get; set; }
public string? BillingEmail { get; set; }
public string? Phone { get; set; }
public string? MobilePhone { get; set; }
public string? Address { get; set; }
@@ -52,10 +53,13 @@ public class CreateCustomerDto : IValidatableObject
public string? ContactLastName { get; set; }
[Display(Name = "Email")]
[EmailAddress(ErrorMessage = "Please enter a valid email address")]
[StringLength(200)]
[StringLength(1000)]
public string? Email { get; set; }
[Display(Name = "Billing / Accounting Email")]
[StringLength(1000)]
public string? BillingEmail { get; set; }
[Display(Name = "Phone")]
[Phone(ErrorMessage = "Please enter a valid phone number")]
[StringLength(20)]
@@ -136,13 +140,40 @@ public class CreateCustomerDto : IValidatableObject
new[] { nameof(CompanyName), nameof(ContactFirstName), nameof(ContactLastName) });
}
// At least one contact method is required (Email OR Phone)
if (string.IsNullOrWhiteSpace(Email) && string.IsNullOrWhiteSpace(Phone))
// At least one contact method is required (Email, Phone, or Mobile Phone)
if (string.IsNullOrWhiteSpace(Email) && string.IsNullOrWhiteSpace(Phone) && string.IsNullOrWhiteSpace(MobilePhone))
{
yield return new ValidationResult(
"Please provide at least one contact method (Email or Phone)",
new[] { nameof(Email), nameof(Phone) });
"Please provide at least one contact method (Email, Phone, or Mobile Phone)",
new[] { nameof(Email), nameof(Phone), nameof(MobilePhone) });
}
// Validate each address in comma-separated email fields
foreach (var addr in SplitEmails(Email))
{
if (!IsValidEmail(addr))
yield return new ValidationResult(
$"'{addr}' is not a valid email address.",
new[] { nameof(Email) });
}
foreach (var addr in SplitEmails(BillingEmail))
{
if (!IsValidEmail(addr))
yield return new ValidationResult(
$"'{addr}' is not a valid email address.",
new[] { nameof(BillingEmail) });
}
}
private static IEnumerable<string> SplitEmails(string? value) =>
string.IsNullOrWhiteSpace(value)
? []
: value.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
private static bool IsValidEmail(string email)
{
try { _ = new System.Net.Mail.MailAddress(email); return true; }
catch { return false; }
}
}
@@ -16,6 +16,7 @@ public class GiftCertificateListDto
public GiftCertificateStatus Status { get; set; }
public DateTime IssueDate { get; set; }
public DateTime? ExpiryDate { get; set; }
public Guid? BatchId { get; set; }
}
public class GiftCertificateDto : GiftCertificateListDto
@@ -87,3 +88,27 @@ public class RedeemGiftCertificateDto
[Range(0.01, 9999.99)]
public decimal Amount { get; set; }
}
public class BulkCreateGiftCertificateDto
{
[Required]
[Range(1, 500, ErrorMessage = "Quantity must be between 1 and 500.")]
[Display(Name = "Number of Certificates")]
public int Quantity { get; set; } = 25;
[Required]
[Range(1.00, 9999.99, ErrorMessage = "Amount must be between $1.00 and $9,999.99.")]
[Display(Name = "Face Value (each)")]
public decimal Amount { get; set; }
[Required]
[Display(Name = "Issued Reason")]
public GiftCertificateIssuedReason IssuedReason { get; set; } = GiftCertificateIssuedReason.Promotional;
[Display(Name = "Expiry Date (optional)")]
public DateTime? ExpiryDate { get; set; }
[StringLength(1000)]
[Display(Name = "Event / Notes (applied to all certificates)")]
public string? Notes { get; set; }
}
@@ -1,28 +0,0 @@
using CsvHelper.Configuration.Attributes;
namespace PowderCoating.Application.DTOs.Import;
/// <summary>
/// DTO for importing shop workers from CSV files.
/// Valid Role values: GeneralLabor, Sandblaster, Coater, Masker, QualityControl, OvenOperator, Supervisor, Maintenance
/// </summary>
public class ShopWorkerImportDto
{
[Name("Name")]
public string Name { get; set; } = string.Empty;
[Name("Role")]
public string Role { get; set; } = "GeneralLabor";
[Name("Phone")]
public string? Phone { get; set; }
[Name("Email")]
public string? Email { get; set; }
[Name("IsActive")]
public bool? IsActive { get; set; }
[Name("Notes")]
public string? Notes { get; set; }
}
@@ -43,6 +43,7 @@ public class InventoryItemDto
public string? Location { get; set; }
public string? Notes { get; set; }
public bool IsActive { get; set; }
public bool IsIncoming { get; set; }
public DateTime? DiscontinuedDate { get; set; }
public DateTime CreatedAt { get; set; }
public bool IsLowStock { get; set; }
@@ -74,6 +75,7 @@ public class InventoryListDto
public int? PrimaryVendorId { get; set; }
public string? PrimaryVendorName { get; set; }
public bool IsActive { get; set; }
public bool IsIncoming { get; set; }
public bool IsLowStock { get; set; }
public bool IsOutOfStock { get; set; }
public bool HasSamplePanel { get; set; }
@@ -217,6 +219,9 @@ public class CreateInventoryItemDto
[Display(Name = "Sample Panel on Wall")]
public bool HasSamplePanel { get; set; }
[Display(Name = "Incoming / On Order")]
public bool IsIncoming { get; set; }
}
public class UpdateInventoryItemDto : CreateInventoryItemDto
@@ -32,7 +32,9 @@ public class InvoiceDto
public string CustomerName { get; set; } = string.Empty;
public string? CustomerEmail { get; set; }
public string? CustomerPhone { get; set; }
public string? CustomerMobilePhone { get; set; }
public bool CustomerNotifyByEmail { get; set; }
public bool CustomerNotifyBySms { get; set; }
public string? PreparedById { get; set; }
public string? PreparedByName { get; set; }
public InvoiceStatus Status { get; set; }
@@ -82,6 +84,10 @@ public class CreateInvoiceDto
public string? InternalNotes { get; set; }
public string? Terms { get; set; }
public string? CustomerPO { get; set; }
/// <summary>Early-payment discount percentage parsed from the customer's payment terms (e.g., 2.0 for "2/10 Net 30"). Informational — does not auto-apply.</summary>
public decimal EarlyPaymentDiscountPercent { get; set; }
/// <summary>Number of days within which the early-payment discount applies (e.g., 10 for "2/10 Net 30").</summary>
public int EarlyPaymentDiscountDays { get; set; }
public List<CreateInvoiceItemDto> InvoiceItems { get; set; } = new();
}
@@ -36,6 +36,8 @@ public class IssueRefundDto
public decimal Amount { get; set; }
public DateTime RefundDate { get; set; } = DateTime.Today;
public PaymentMethod RefundMethod { get; set; }
/// <summary>Bank/cash account money leaves when issuing a cash/card refund. Null for store credit.</summary>
public int? DepositAccountId { get; set; }
public string Reason { get; set; } = string.Empty;
public string? Reference { get; set; }
public string? Notes { get; set; }
@@ -45,6 +45,12 @@ public class JobDto
public decimal QuotedPrice { get; set; }
public decimal FinalPrice { get; set; }
public decimal ShopSuppliesAmount { get; set; }
public decimal ShopSuppliesPercent { get; set; }
public bool IsRushJob { get; set; }
public string DiscountType { get; set; } = "None";
public decimal DiscountValue { get; set; }
public string? DiscountReason { get; set; }
public string? CustomerPO { get; set; }
public string? SpecialInstructions { get; set; }
public string? InternalNotes { get; set; }
@@ -131,6 +137,13 @@ public class CreateJobDto
[Display(Name = "Oven")]
public int? OvenCostId { get; set; }
[Display(Name = "Batches")]
[Range(1, 999)]
public int OvenBatches { get; set; } = 1;
[Display(Name = "Cycle Time (min)")]
public int? OvenCycleMinutes { get; set; }
[Required(ErrorMessage = "Description is required")]
[StringLength(2000, ErrorMessage = "Description cannot exceed 2000 characters")]
[Display(Name = "Description")]
@@ -202,6 +215,16 @@ public class UpdateJobDto
[Display(Name = "Assigned Worker")]
public string? AssignedUserId { get; set; }
[Display(Name = "Oven")]
public int? OvenCostId { get; set; }
[Display(Name = "Batches")]
[Range(1, 999)]
public int OvenBatches { get; set; } = 1;
[Display(Name = "Cycle Time (min)")]
public int? OvenCycleMinutes { get; set; }
[Required(ErrorMessage = "Description is required")]
[StringLength(2000, ErrorMessage = "Description cannot exceed 2000 characters")]
[Display(Name = "Description")]
@@ -375,6 +398,7 @@ public class JobItemCoatDto
public decimal? PowderCostPerLb { get; set; }
public decimal? PowderToOrder { get; set; }
public decimal? ActualPowderUsedLbs { get; set; } // Filled during job completion
public bool NoExtraLayerCharge { get; set; }
public string? Notes { get; set; }
}
@@ -383,7 +407,7 @@ public class CompleteJobDto
{
public int JobId { get; set; }
public decimal? ActualTimeSpentHours { get; set; }
public List<JobItemCoatUsageDto> CoatUsages { get; set; } = new();
public List<JobPowderUsageDto> PowderUsages { get; set; } = new();
public bool SendEmailToCustomer { get; set; } = false;
}
@@ -394,10 +418,10 @@ public class SendJobSmsRequest
public string Message { get; set; } = string.Empty;
}
// DTO for tracking actual powder usage per coat
public class JobItemCoatUsageDto
// DTO for tracking actual powder usage per inventory item (color) for the whole job
public class JobPowderUsageDto
{
public int JobItemCoatId { get; set; }
public int InventoryItemId { get; set; }
public decimal? ActualPowderUsedLbs { get; set; }
}
@@ -509,6 +533,9 @@ public class JobEditItemsViewModel
public string JobNumber { get; set; } = string.Empty;
public int? CustomerId { get; set; }
public decimal TaxPercent { get; set; }
public int? OvenCostId { get; set; }
public int OvenBatches { get; set; } = 1;
public int? OvenCycleMinutes { get; set; }
public List<CreateQuoteItemDto> JobItems { get; set; } = new();
}
@@ -0,0 +1,88 @@
using System.ComponentModel.DataAnnotations;
using PowderCoating.Core.Enums;
namespace PowderCoating.Application.DTOs.Kiosk;
// ── Staff-facing ──────────────────────────────────────────────────────────────
/// <summary>Input for sending a remote intake link to a customer by email.</summary>
public class SendRemoteLinkDto
{
[Required, EmailAddress]
public string Email { get; set; } = string.Empty;
/// <summary>Optional — used to personalise the email greeting.</summary>
public string? CustomerName { get; set; }
}
// ── Customer-facing step DTOs ─────────────────────────────────────────────────
/// <summary>Step 1 — Contact information submitted by the customer.</summary>
public class SubmitKioskContactDto
{
[Required, MaxLength(100)]
public string FirstName { get; set; } = string.Empty;
[Required, MaxLength(100)]
public string LastName { get; set; } = string.Empty;
[Required, Phone]
public string Phone { get; set; } = string.Empty;
[Required, EmailAddress]
public string Email { get; set; } = string.Empty;
public bool IsReturningCustomer { get; set; }
}
/// <summary>Step 2 — Job description submitted by the customer.</summary>
public class SubmitKioskJobDto
{
[Required, MaxLength(2000)]
public string JobDescription { get; set; } = string.Empty;
public string? HowDidYouHearAboutUs { get; set; }
}
/// <summary>Step 3 — Terms agreement (+ optional drawn signature for in-person sessions).</summary>
public class SubmitKioskTermsDto
{
[Required]
[Range(typeof(bool), "true", "true", ErrorMessage = "You must agree to the terms to continue.")]
public bool AgreedToTerms { get; set; }
public bool SmsOptIn { get; set; }
/// <summary>Base-64 PNG from signature_pad; required for InPerson sessions, null for Remote.</summary>
public string? SignatureDataBase64 { get; set; }
}
// ── Staff review list ─────────────────────────────────────────────────────────
/// <summary>One row in the Kiosk Intakes staff review list.</summary>
public class KioskSessionListDto
{
public int Id { get; set; }
public Guid SessionToken { get; set; }
public KioskSessionType SessionType { get; set; }
public KioskSessionStatus Status { get; set; }
public string CustomerFirstName { get; set; } = string.Empty;
public string CustomerLastName { get; set; } = string.Empty;
public string CustomerEmail { get; set; } = string.Empty;
public string CustomerPhone { get; set; } = string.Empty;
public string JobDescription { get; set; } = string.Empty;
public bool SmsOptIn { get; set; }
public DateTime? SubmittedAt { get; set; }
public DateTime ExpiresAt { get; set; }
public int? LinkedCustomerId { get; set; }
public int? LinkedJobId { get; set; }
public int? LinkedQuoteId { get; set; }
public string? RemoteLinkEmail { get; set; }
public string CustomerFullName => $"{CustomerFirstName} {CustomerLastName}".Trim();
public string JobDescriptionSnippet =>
JobDescription.Length > 80 ? JobDescription[..80] + "…" : JobDescription;
public bool IsConverted => LinkedJobId.HasValue || LinkedQuoteId.HasValue;
public bool IsExpired => Status == KioskSessionStatus.Expired ||
(Status == KioskSessionStatus.Active && DateTime.UtcNow > ExpiresAt);
}
@@ -59,6 +59,8 @@ public class QuoteDto
public string? ProspectCity { get; set; }
public string? ProspectState { get; set; }
public string? ProspectZipCode { get; set; }
public bool ProspectSmsConsent { get; set; }
public DateTime? ProspectSmsConsentedAt { get; set; }
public string? PreparedById { get; set; }
public string? PreparedByName { get; set; }
@@ -127,6 +129,7 @@ public class QuoteDto
// Conversion Tracking
public int? ConvertedToJobId { get; set; }
public string? ConvertedToJobNumber { get; set; }
// Customer Approval Tracking
public string? ApprovalToken { get; set; }
@@ -185,6 +188,9 @@ public class CreateQuoteDto
[StringLength(10)]
public string? ProspectZipCode { get; set; }
[Display(Name = "SMS Consent")]
public bool ProspectSmsConsent { get; set; } = false;
// Oven Selection
[Display(Name = "Oven")]
public int? OvenCostId { get; set; }
@@ -321,6 +327,9 @@ public class UpdateQuoteDto
[StringLength(10)]
public string? ProspectZipCode { get; set; }
[Display(Name = "SMS Consent")]
public bool ProspectSmsConsent { get; set; } = false;
// Oven Selection
[Display(Name = "Oven")]
public int? OvenCostId { get; set; }
@@ -595,6 +604,11 @@ public class QuotePricingBreakdownDto
public decimal SubtotalBeforeDiscount { get; set; }
public decimal PricingTierDiscountAmount { get; set; }
public decimal PricingTierDiscountPercent { get; set; }
public decimal QuoteDiscountAmount { get; set; }
public decimal QuoteDiscountPercent { get; set; }
public decimal DiscountAmount { get; set; }
public decimal DiscountPercent { get; set; }
@@ -684,6 +698,16 @@ public class ConvertQuoteToCustomerDto
[Display(Name = "Notes")]
[DataType(DataType.MultilineText)]
public string? Notes { get; set; }
/// <summary>
/// Staff must explicitly confirm verbal SMS consent before it carries over to the new customer record.
/// Pre-checked when ProspectSmsConsent was true on the source quote.
/// </summary>
[Display(Name = "Customer has given verbal consent to receive SMS notifications")]
public bool SmsConsent { get; set; }
/// <summary>Timestamp from the source quote — preserved so the consent record reflects when consent was originally given.</summary>
public DateTime? ProspectSmsConsentedAt { get; set; }
}
// ============================================================================
@@ -744,6 +768,16 @@ public class CreateQuoteItemCoatDto
/// When true, the additional layer labor charge is not applied even if this is not the first coat.
/// </summary>
public bool NoExtraLayerCharge { get; set; }
/// <summary>Platform powder catalog item ID selected via the Custom tab lookup.</summary>
public int? CatalogItemId { get; set; }
/// <summary>
/// When true (and CatalogItemId is set), the server creates a 0-balance IsIncoming inventory
/// item from the catalog entry so QR codes can be printed while the powder is in transit.
/// The coat is then linked to that new inventory record.
/// </summary>
public bool AddAsIncoming { get; set; }
}
/// <summary>
@@ -767,6 +801,7 @@ public class QuoteItemCoatDto
public decimal CoatMaterialCost { get; set; }
public decimal CoatLaborCost { get; set; }
public decimal CoatTotalCost { get; set; }
public bool NoExtraLayerCharge { get; set; }
public string? Notes { get; set; }
}
@@ -1,27 +0,0 @@
using System.ComponentModel.DataAnnotations;
using PowderCoating.Core.Enums;
namespace PowderCoating.Application.DTOs.ShopWorker;
public class CreateShopWorkerDto
{
[Required(ErrorMessage = "Worker name is required")]
[StringLength(100, ErrorMessage = "Name cannot exceed 100 characters")]
public string Name { get; set; } = string.Empty;
[Required(ErrorMessage = "Role is required")]
public ShopWorkerRole Role { get; set; } = ShopWorkerRole.GeneralLabor;
[Phone(ErrorMessage = "Invalid phone number format")]
[StringLength(20, ErrorMessage = "Phone cannot exceed 20 characters")]
public string? Phone { get; set; }
[EmailAddress(ErrorMessage = "Invalid email address format")]
[StringLength(100, ErrorMessage = "Email cannot exceed 100 characters")]
public string? Email { get; set; }
public bool IsActive { get; set; } = true;
[StringLength(500, ErrorMessage = "Notes cannot exceed 500 characters")]
public string? Notes { get; set; }
}
@@ -1,16 +0,0 @@
using PowderCoating.Core.Enums;
namespace PowderCoating.Application.DTOs.ShopWorker;
public class ShopWorkerDto
{
public int Id { get; set; }
public string Name { get; set; } = string.Empty;
public ShopWorkerRole Role { get; set; }
public string? Phone { get; set; }
public string? Email { get; set; }
public bool IsActive { get; set; }
public string? Notes { get; set; }
public DateTime CreatedAt { get; set; }
public DateTime? UpdatedAt { get; set; }
}
@@ -1,29 +0,0 @@
using System.ComponentModel.DataAnnotations;
using PowderCoating.Core.Enums;
namespace PowderCoating.Application.DTOs.ShopWorker;
public class UpdateShopWorkerDto
{
public int Id { get; set; }
[Required(ErrorMessage = "Worker name is required")]
[StringLength(100, ErrorMessage = "Name cannot exceed 100 characters")]
public string Name { get; set; } = string.Empty;
[Required(ErrorMessage = "Role is required")]
public ShopWorkerRole Role { get; set; }
[Phone(ErrorMessage = "Invalid phone number format")]
[StringLength(20, ErrorMessage = "Phone cannot exceed 20 characters")]
public string? Phone { get; set; }
[EmailAddress(ErrorMessage = "Invalid email address format")]
[StringLength(100, ErrorMessage = "Email cannot exceed 100 characters")]
public string? Email { get; set; }
public bool IsActive { get; set; }
[StringLength(500, ErrorMessage = "Notes cannot exceed 500 characters")]
public string? Notes { get; set; }
}
@@ -41,6 +41,8 @@ public class CompanyUserDto
public bool CanManageMaintenance { get; set; }
public bool CanManageInvoices { get; set; }
public bool CanViewReports { get; set; }
public bool CanManageBills { get; set; }
public bool CanManageAccounting { get; set; }
}
/// <summary>
@@ -156,6 +158,12 @@ public class CreateCompanyUserDto
[Display(Name = "Can View Reports")]
public bool CanViewReports { get; set; }
[Display(Name = "Can Manage Bills & AP")]
public bool CanManageBills { get; set; }
[Display(Name = "Can Manage Accounting")]
public bool CanManageAccounting { get; set; }
[Display(Name = "Send Welcome Email")]
public bool SendWelcomeEmail { get; set; } = true;
}
@@ -209,6 +217,10 @@ public class UpdateCompanyUserDto
[Display(Name = "Active")]
public bool IsActive { get; set; }
[Range(0, 10000, ErrorMessage = "Labor cost rate must be between 0 and 10,000")]
[Display(Name = "Labor Cost Rate ($/hr)")]
public decimal? LaborCostPerHour { get; set; }
[Required(ErrorMessage = "Hire date is required")]
[Display(Name = "Hire Date")]
public DateTime HireDate { get; set; }
@@ -258,4 +270,10 @@ public class UpdateCompanyUserDto
[Display(Name = "Can View Reports")]
public bool CanViewReports { get; set; }
[Display(Name = "Can Manage Bills & AP")]
public bool CanManageBills { get; set; }
[Display(Name = "Can Manage Accounting")]
public bool CanManageAccounting { get; set; }
}
@@ -120,6 +120,9 @@ public class CreateVendorDto
[Display(Name = "Preferred Vendor")]
public bool IsPreferred { get; set; } = false;
[Display(Name = "1099 Vendor")]
public bool Is1099Vendor { get; set; } = false;
[Display(Name = "Default Expense Account")]
public int? DefaultExpenseAccountId { get; set; }
}
@@ -201,6 +204,9 @@ public class UpdateVendorDto
[Display(Name = "Preferred Vendor")]
public bool IsPreferred { get; set; }
[Display(Name = "1099 Vendor")]
public bool Is1099Vendor { get; set; }
[Display(Name = "Default Expense Account")]
public int? DefaultExpenseAccountId { get; set; }
}
@@ -43,4 +43,33 @@ public interface IAccountingAiService
/// Returns a ranked list of flagged items with recommended actions.
/// </summary>
Task<AnomalyDetectionResult> DetectAnomaliesAsync(AnomalyDetectionRequest request);
/// <summary>
/// Suggests which uncleared bank rec items should be marked as cleared to reconcile
/// a statement. Returns a ranked list of suggestions with confidence scores based on
/// amount/date patterns and the gap between the current cleared balance and the
/// statement ending balance.
/// </summary>
Task<AutoMatchResult> AutoMatchReconciliationAsync(AutoMatchRequest request);
/// <summary>
/// Predicts likelihood of late payment for each open AR customer using their historical
/// payment behavior (avg days to pay, late rate) combined with current overdue status.
/// Returns risk levels (high/medium/low) and estimated days to collection.
/// </summary>
Task<LatePaymentPredictionResult> PredictLatePaymentsAsync(LatePaymentPredictionRequest request);
/// <summary>
/// Answers a plain-English financial question (e.g. "What did we spend on powder last quarter?")
/// using pre-loaded company financial context. Returns a direct answer, supporting facts,
/// and an optional follow-up question suggestion.
/// </summary>
Task<FinancialQueryResult> AnswerFinancialQueryAsync(FinancialQueryRequest request);
/// <summary>
/// Analyzes 612 months of bill history to detect recurring payment patterns per vendor.
/// Returns detected patterns with frequency, typical amount, next expected date, and
/// suggested actions (e.g. set a reminder, create a template).
/// </summary>
Task<RecurringBillDetectionResult> DetectRecurringBillsAsync(RecurringBillDetectionRequest request);
}
@@ -136,18 +136,7 @@ public interface ICsvImportService
/// </summary>
Task<CsvImportResultDto> ImportVendorsAsync(Stream csvStream, int companyId);
/// <summary>
/// Generate a CSV template file for shop worker imports.
/// </summary>
byte[] GenerateShopWorkerTemplate();
/// <summary>
/// Import shop workers from a CSV stream.
/// Updates existing workers matched by Name; creates new ones otherwise.
/// </summary>
Task<CsvImportResultDto> ImportShopWorkersAsync(Stream csvStream, int companyId);
/// <summary>
/// <summary>
/// Generate a CSV template file for prep service imports.
/// </summary>
byte[] GeneratePrepServiceTemplate();
@@ -1,4 +1,5 @@
using PowderCoating.Application.DTOs.Accounting;
using PowderCoating.Core.Enums;
namespace PowderCoating.Application.Interfaces;
@@ -6,14 +7,16 @@ namespace PowderCoating.Application.Interfaces;
/// Read-only service for financial aggregate reports. All methods query the database
/// with AsNoTracking and return pre-shaped DTOs — no tracked entities are returned.
/// Implemented in Infrastructure; uses ApplicationDbContext directly.
/// The <paramref name="method"/> parameter overrides the company's stored preference when
/// supplied; pass <c>null</c> to fall back to the company's configured accounting method.
/// </summary>
public interface IFinancialReportService
{
/// <summary>Returns a Profit &amp; Loss report for the given company and date range.</summary>
Task<ProfitAndLossDto> GetProfitAndLossAsync(int companyId, DateTime from, DateTime to);
Task<ProfitAndLossDto> GetProfitAndLossAsync(int companyId, DateTime from, DateTime to, AccountingMethod? method = null);
/// <summary>Returns a Balance Sheet snapshot as of the given date.</summary>
Task<BalanceSheetDto> GetBalanceSheetAsync(int companyId, DateTime asOf);
Task<BalanceSheetDto> GetBalanceSheetAsync(int companyId, DateTime asOf, AccountingMethod? method = null);
/// <summary>Returns an AR Aging report bucketed at 0-30, 31-60, 61-90, and 90+ days.</summary>
Task<ArAgingReportDto> GetArAgingAsync(int companyId, DateTime asOf);
@@ -23,4 +26,27 @@ public interface IFinancialReportService
/// <summary>Returns an invoice-basis Sales Tax Liability report for the given company and date range.</summary>
Task<SalesTaxReportDto> GetSalesTaxReportAsync(int companyId, DateTime from, DateTime to);
/// <summary>Returns an AP Aging report bucketed at 0-30, 31-60, 61-90, and 90+ days past the bill due date.</summary>
Task<ApAgingReportDto> GetApAgingAsync(int companyId, DateTime asOf);
/// <summary>Returns a Trial Balance using current account balances as of the given date.</summary>
Task<TrialBalanceDto> GetTrialBalanceAsync(int companyId, DateTime asOf);
/// <summary>Looks up the accounting method configured for the given company. Returns Accrual if not found.</summary>
Task<AccountingMethod> GetCompanyAccountingMethodAsync(int companyId);
/// <summary>Returns a dated activity statement for a customer showing opening balance, all transactions in the period, and closing balance.</summary>
Task<CustomerStatementDto> GetCustomerStatementAsync(int companyId, int customerId, DateTime from, DateTime to);
/// <summary>Returns a dated activity statement for a vendor showing opening balance, all transactions in the period, and closing balance.</summary>
Task<VendorStatementDto> GetVendorStatementAsync(int companyId, int vendorId, DateTime from, DateTime to);
/// <summary>
/// Returns a Cash Flow Statement for the period using the direct (cash-basis) method for
/// operating activities. Investing and Financing sections are derived from account-level data.
/// BeginningCash is computed from all cash/bank account credits and debits prior to
/// <paramref name="from"/>; EndingCash adds the net change during the period.
/// </summary>
Task<CashFlowStatementDto> GetCashFlowStatementAsync(int companyId, DateTime from, DateTime to);
}
@@ -47,8 +47,10 @@ public interface IInventoryAiLookupService
/// <summary>
/// Fetch cure specs, color families, finish, and clear-coat data from a known product URL.
/// Skips the Serper search step; used after a catalog hit to augment catalog fields.
/// When <paramref name="tdsFallbackUrl"/> is supplied and cure specs are still null after
/// the main fetch, the TDS page is tried automatically before returning.
/// </summary>
Task<InventoryAiLookupResult> LookupByUrlAsync(string url, string? colorName);
Task<InventoryAiLookupResult> LookupByUrlAsync(string url, string? colorName, string? tdsFallbackUrl = null);
/// <summary>
/// Read a powder label photo and extract manufacturer, color name, SKU, and cure specs
@@ -0,0 +1,19 @@
using PowderCoating.Application.DTOs.Quote;
using PowderCoating.Core.Entities;
namespace PowderCoating.Application.Interfaces;
public interface IJobItemAssemblyService
{
JobItem CreateJobItem(CreateQuoteItemDto source, int jobId, int companyId, QuoteItemPricingResult pricing, DateTime createdAtUtc);
IReadOnlyList<JobItemCoat> CreateJobItemCoats(CreateQuoteItemDto source, int jobItemId, int companyId, DateTime createdAtUtc);
IReadOnlyList<JobItemPrepService> CreateJobItemPrepServices(CreateQuoteItemDto source, int jobItemId, int companyId, DateTime createdAtUtc);
JobItem CreateJobItem(QuoteItem source, int jobId, int companyId, DateTime createdAtUtc);
IReadOnlyList<JobItemCoat> CreateJobItemCoats(QuoteItem source, int jobItemId, int companyId, DateTime createdAtUtc);
IReadOnlyList<JobItemPrepService> CreateJobItemPrepServices(QuoteItem source, int jobItemId, int companyId, DateTime createdAtUtc);
JobItem CreateJobItem(JobItem source, int jobId, int companyId, DateTime createdAtUtc);
IReadOnlyList<JobItemCoat> CreateJobItemCoats(JobItem source, int jobItemId, int companyId, DateTime createdAtUtc);
IReadOnlyList<JobItemPrepService> CreateJobItemPrepServices(JobItem source, int jobItemId, int companyId, DateTime createdAtUtc);
}
@@ -9,7 +9,7 @@ public interface INotificationService
/// Notify when a quote is created/sent. Handles both registered customers and prospects.
/// Optionally attaches the quote PDF to the email.
/// </summary>
Task NotifyQuoteSentAsync(Quote quote, byte[]? pdfAttachment = null, string? pdfFilename = null);
Task NotifyQuoteSentAsync(Quote quote, byte[]? pdfAttachment = null, string? pdfFilename = null, string? overrideEmail = null);
/// <summary>
/// Sends the quote approval link to the customer via SMS.
@@ -58,7 +58,7 @@ public interface INotificationService
/// Notify customer when an invoice has been sent.
/// Optionally includes an online payment link in the email body.
/// </summary>
Task NotifyInvoiceSentAsync(Invoice invoice, byte[]? pdfAttachment = null, string? pdfFilename = null, string? paymentUrl = null);
Task NotifyInvoiceSentAsync(Invoice invoice, byte[]? pdfAttachment = null, string? pdfFilename = null, string? paymentUrl = null, string? overrideEmail = null, bool sendSms = false, string? viewUrl = null);
/// <summary>
/// Notify customer (internal) when a payment has been recorded on an invoice.
@@ -91,4 +91,11 @@ public interface INotificationService
/// Alert company staff when a Stripe chargeback (dispute) is opened on an invoice payment.
/// </summary>
Task NotifyChargebackAlertAsync(Invoice invoice, string disputeId, decimal amount, string reason);
/// <summary>
/// Sends an appointment reminder email to the linked customer (if opted in) and writes a
/// notification log row. Called by <see cref="PowderCoating.Web.BackgroundServices.AppointmentReminderBackgroundService"/>
/// when the reminder window opens. In-app bell notification is handled by the caller.
/// </summary>
Task NotifyAppointmentReminderAsync(Appointment appointment);
}
@@ -42,10 +42,19 @@ public interface IPdfService
Task<byte[]> GenerateArAgingPdfAsync(ArAgingReportDto dto);
Task<byte[]> GenerateSalesAndIncomePdfAsync(SalesIncomeReportDto dto);
Task<byte[]> GenerateSalesTaxReportPdfAsync(SalesTaxReportDto dto);
Task<byte[]> GenerateApAgingPdfAsync(ApAgingReportDto dto);
Task<byte[]> GenerateTrialBalancePdfAsync(TrialBalanceDto dto);
Task<byte[]> GenerateCashFlowStatementPdfAsync(CashFlowStatementDto dto);
Task<byte[]> GenerateGiftCertificatePdfAsync(
GiftCertificateDto cert,
byte[]? companyLogo,
string? companyLogoContentType,
CompanyInfoDto companyInfo);
Task<byte[]> GenerateBulkGiftCertificatePdfAsync(
IList<GiftCertificateDto> certs,
byte[]? companyLogo,
string? companyLogoContentType,
CompanyInfoDto companyInfo);
}
@@ -0,0 +1,16 @@
using PowderCoating.Application.DTOs.Quote;
using PowderCoating.Core.Entities;
namespace PowderCoating.Application.Interfaces;
public interface IQuotePricingAssemblyService
{
void ApplyPricingSnapshot(Quote quote, QuotePricingResult pricingResult);
Task<IReadOnlyList<QuoteItem>> CreateQuoteItemsAsync(
IEnumerable<CreateQuoteItemDto> itemDtos,
int quoteId,
int companyId,
decimal? ovenRateOverride,
DateTime createdAtUtc);
}
@@ -54,5 +54,6 @@ public class CompanyProfile : Profile
CreateMap<UpdateQuoteTemplateDto, CompanyPreferences>();
CreateMap<UpdateInvoiceTemplateDto, CompanyPreferences>();
CreateMap<UpdateWorkOrderTemplateDto, CompanyPreferences>();
CreateMap<UpdateKioskSettingsDto, CompanyPreferences>();
}
}
@@ -24,9 +24,13 @@ public class InvoiceProfile : Profile
? s.Customer.CompanyName
: $"{s.Customer.ContactFirstName} {s.Customer.ContactLastName}".Trim())
: string.Empty))
.ForMember(d => d.CustomerEmail, o => o.MapFrom(s => s.Customer != null ? s.Customer.Email : null))
.ForMember(d => d.CustomerEmail, o => o.MapFrom(s => s.Customer != null
? (s.Customer.BillingEmail ?? s.Customer.Email)
: null))
.ForMember(d => d.CustomerPhone, o => o.MapFrom(s => s.Customer != null ? s.Customer.Phone : null))
.ForMember(d => d.CustomerMobilePhone, o => o.MapFrom(s => s.Customer != null ? s.Customer.MobilePhone : null))
.ForMember(d => d.CustomerNotifyByEmail, o => o.MapFrom(s => s.Customer == null || s.Customer.NotifyByEmail))
.ForMember(d => d.CustomerNotifyBySms, o => o.MapFrom(s => s.Customer != null && s.Customer.NotifyBySms))
.ForMember(d => d.PreparedByName, o => o.MapFrom(s => s.PreparedBy != null
? $"{s.PreparedBy.FirstName} {s.PreparedBy.LastName}".Trim()
: null))
@@ -52,6 +52,7 @@ public class JobProfile : Profile
.ForMember(dest => dest.PrepServiceIds, opt => opt.MapFrom(src =>
src.JobPrepServices.Select(jps => jps.PrepServiceId).ToList()))
.ForMember(dest => dest.TimeEntries, opt => opt.MapFrom(src => src.TimeEntries))
.ForMember(dest => dest.DiscountType, opt => opt.MapFrom(src => src.DiscountType.ToString()))
.ForMember(dest => dest.IsReworkJob, opt => opt.MapFrom(src => src.IsReworkJob))
.ForMember(dest => dest.OriginalJobId, opt => opt.MapFrom(src => src.OriginalJobId))
.ForMember(dest => dest.OriginalJobNumber,
@@ -72,7 +73,7 @@ public class JobProfile : Profile
// JobTimeEntry → JobTimeEntryDto
CreateMap<JobTimeEntry, JobTimeEntryDto>()
.ForMember(dest => dest.WorkerName, opt => opt.MapFrom(src =>
src.UserDisplayName ?? (src.Worker != null ? src.Worker.Name : string.Empty)));
src.UserDisplayName ?? string.Empty));
// CreateJobDto to Job
CreateMap<CreateJobDto, Job>()
@@ -35,7 +35,7 @@ public class QuoteProfile : Profile
.ForMember(dest => dest.CustomerName, opt => opt.MapFrom(src =>
src.Customer != null ? src.Customer.CompanyName : null))
.ForMember(dest => dest.CustomerEmail, opt => opt.MapFrom(src =>
src.Customer != null ? src.Customer.Email : null))
src.Customer != null ? src.Customer.Email : src.ProspectEmail))
.ForMember(dest => dest.CustomerMobilePhone, opt => opt.MapFrom(src =>
src.Customer != null ? src.Customer.MobilePhone : null))
.ForMember(dest => dest.CustomerNotifyBySms, opt => opt.MapFrom(src =>
@@ -78,6 +78,7 @@ public class QuoteProfile : Profile
// CreateQuoteDto -> Quote
CreateMap<CreateQuoteDto, Quote>()
.ForMember(dest => dest.Id, opt => opt.Ignore())
.ForMember(dest => dest.ProspectSmsConsentedAt, opt => opt.Ignore()) // Set by controller on consent
.ForMember(dest => dest.QuoteNumber, opt => opt.Ignore()) // Generated by controller
.ForMember(dest => dest.QuoteStatus, opt => opt.Ignore()) // Will be set by FK to Draft status
.ForMember(dest => dest.OvenCost, opt => opt.Ignore())
@@ -111,6 +112,7 @@ public class QuoteProfile : Profile
// UpdateQuoteDto -> Quote
CreateMap<UpdateQuoteDto, Quote>()
.ForMember(dest => dest.QuoteNumber, opt => opt.Ignore()) // Cannot change
.ForMember(dest => dest.ProspectSmsConsentedAt, opt => opt.Ignore()) // Managed by controller
.ForMember(dest => dest.CustomerId, opt => opt.Ignore()) // Cannot change after creation - preserved in controller
.ForMember(dest => dest.QuoteStatus, opt => opt.Ignore()) // Will be set by FK
.ForMember(dest => dest.OvenCost, opt => opt.Ignore())
@@ -277,6 +279,8 @@ public class QuoteProfile : Profile
.ForMember(dest => dest.ZipCode, opt => opt.MapFrom(src => src.ProspectZipCode))
.ForMember(dest => dest.IsCommercial, opt => opt.MapFrom(src => src.IsCommercial))
.ForMember(dest => dest.CreditLimit, opt => opt.MapFrom(src => 0m))
.ForMember(dest => dest.SmsConsent, opt => opt.MapFrom(src => src.ProspectSmsConsent))
.ForMember(dest => dest.ProspectSmsConsentedAt, opt => opt.MapFrom(src => src.ProspectSmsConsentedAt))
.ForMember(dest => dest.PricingTierId, opt => opt.Ignore())
.ForMember(dest => dest.TaxId, opt => opt.Ignore())
.ForMember(dest => dest.PaymentTerms, opt => opt.Ignore())
@@ -1,23 +0,0 @@
using AutoMapper;
using PowderCoating.Application.DTOs.ShopWorker;
using PowderCoating.Core.Entities;
namespace PowderCoating.Application.Mappings;
public class ShopWorkerProfile : Profile
{
public ShopWorkerProfile()
{
// Entity to DTO
CreateMap<ShopWorker, ShopWorkerDto>();
// DTO to Entity
CreateMap<CreateShopWorkerDto, ShopWorker>();
CreateMap<UpdateShopWorkerDto, ShopWorker>();
// Reverse mappings
CreateMap<ShopWorkerDto, ShopWorker>();
CreateMap<ShopWorker, CreateShopWorkerDto>();
CreateMap<ShopWorker, UpdateShopWorkerDto>();
}
}
@@ -0,0 +1,59 @@
using Microsoft.AspNetCore.Http;
namespace PowderCoating.Application.Services;
/// <summary>
/// Shared file validation and content-type resolution used across all blob storage services.
/// </summary>
public static class BlobFileHelper
{
/// <summary>
/// Validates an uploaded file against an extension allowlist and a maximum size.
/// Returns the normalized (lowercase) extension on success so callers do not re-derive it.
/// </summary>
public static (bool IsValid, string Extension, string Error) ValidateUpload(
IFormFile? file,
string[] allowedExtensions,
long maxBytes)
{
if (file == null || file.Length == 0)
return (false, string.Empty, "No file provided.");
if (file.Length > maxBytes)
return (false, string.Empty, $"File exceeds the {maxBytes / 1024 / 1024} MB limit.");
var extension = Path.GetExtension(file.FileName).ToLowerInvariant();
if (string.IsNullOrEmpty(extension) || !allowedExtensions.Contains(extension))
return (false, string.Empty, $"File type not allowed. Allowed: {string.Join(", ", allowedExtensions)}.");
return (true, extension, string.Empty);
}
/// <summary>
/// Maps a file extension to its MIME content type, covering common image formats and
/// document types. Falls back to <c>application/octet-stream</c>.
/// </summary>
public static string GetContentType(string extension) => extension switch
{
".jpg" or ".jpeg" => "image/jpeg",
".png" => "image/png",
".gif" => "image/gif",
".webp" => "image/webp",
".svg" => "image/svg+xml",
".pdf" => "application/pdf",
".doc" => "application/msword",
".docx" => "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
".txt" => "text/plain",
_ => "application/octet-stream"
};
/// <summary>
/// Strips OS-invalid filename characters from a base filename (no extension), replacing
/// them with underscores to produce a safe blob path segment.
/// </summary>
public static string SanitizeFileName(string fileName)
{
var sanitized = string.Join("_", fileName.Split(Path.GetInvalidFileNameChars()));
return string.IsNullOrWhiteSpace(sanitized) ? "file" : sanitized;
}
}
@@ -47,15 +47,9 @@ public class CatalogImageService : ICatalogImageService
string? existingImagePath,
string? existingThumbnailPath)
{
if (file == null || file.Length == 0)
return (false, string.Empty, string.Empty, "No file provided.");
if (file.Length > MaxFileSizeBytes)
return (false, string.Empty, string.Empty, "File exceeds the 10 MB limit.");
var ext = Path.GetExtension(file.FileName).ToLowerInvariant();
if (!AllowedExtensions.Contains(ext))
return (false, string.Empty, string.Empty, $"File type '{ext}' is not allowed. Accepted types: jpg, jpeg, png, gif, webp.");
var (isValid, ext, validationError) = BlobFileHelper.ValidateUpload(file, AllowedExtensions, MaxFileSizeBytes);
if (!isValid)
return (false, string.Empty, string.Empty, validationError);
var container = _settings.Containers.CatalogImages;
var blobId = Guid.NewGuid().ToString("N");
@@ -67,21 +67,15 @@ public class CompanyLogoService : ICompanyLogoService
/// </returns>
public async Task<(bool Success, string FilePath, string ErrorMessage)> SaveCompanyLogoAsync(IFormFile file, int companyId)
{
if (file == null || file.Length == 0)
return (false, string.Empty, "No file provided");
if (file.Length > MaxFileSize)
return (false, string.Empty, $"File size exceeds maximum allowed size of {MaxFileSize / 1024 / 1024} MB");
var extension = Path.GetExtension(file.FileName).ToLowerInvariant();
if (!AllowedExtensions.Contains(extension))
return (false, string.Empty, $"File type not allowed. Allowed types: {string.Join(", ", AllowedExtensions)}");
var (isValid, extension, error) = BlobFileHelper.ValidateUpload(file, AllowedExtensions, MaxFileSize);
if (!isValid)
return (false, string.Empty, error);
// Delete old logo (any extension) before saving new one
await DeleteOldLogosAsync(companyId, extension);
var blobName = GetCompanyLogoPath(companyId, extension);
var contentType = GetContentType(extension);
var contentType = BlobFileHelper.GetContentType(extension);
using var stream = file.OpenReadStream();
var result = await _blobService.UploadAsync(_settings.Containers.CompanyLogos, blobName, stream, contentType);
@@ -158,20 +152,4 @@ public class CompanyLogoService : ICompanyLogoService
}
}
/// <summary>
/// Maps a lowercase file extension to its canonical MIME content type.
/// The correct content type is required so that browsers render the image
/// inline rather than triggering a download.
/// </summary>
/// <param name="extension">Lowercase file extension including the leading dot.</param>
/// <returns>MIME type string, or <c>application/octet-stream</c> as a safe fallback.</returns>
private static string GetContentType(string extension) => extension switch
{
".jpg" or ".jpeg" => "image/jpeg",
".png" => "image/png",
".gif" => "image/gif",
".webp" => "image/webp",
".svg" => "image/svg+xml",
_ => "application/octet-stream"
};
}
@@ -56,25 +56,16 @@ public class EquipmentManualService : IEquipmentManualService
/// </returns>
public async Task<(bool Success, string FilePath, string ErrorMessage)> SaveEquipmentManualAsync(IFormFile file, int companyId, int equipmentId)
{
if (file == null || file.Length == 0)
return (false, string.Empty, "No file provided");
if (file.Length > MaxFileSize)
return (false, string.Empty, $"File size exceeds maximum allowed size of {MaxFileSize / 1024 / 1024} MB");
var extension = Path.GetExtension(file.FileName).ToLowerInvariant();
if (!AllowedExtensions.Contains(extension))
return (false, string.Empty, $"File type not allowed. Allowed types: {string.Join(", ", AllowedExtensions)}");
var (isValid, extension, error) = BlobFileHelper.ValidateUpload(file, AllowedExtensions, MaxFileSize);
if (!isValid)
return (false, string.Empty, error);
// Sanitize filename — replace OS-invalid characters with underscores to
// prevent path traversal and blob naming errors in Azure.
var fileName = Path.GetFileNameWithoutExtension(file.FileName);
fileName = string.Join("_", fileName.Split(Path.GetInvalidFileNameChars()));
if (string.IsNullOrWhiteSpace(fileName))
fileName = "manual";
var fileName = BlobFileHelper.SanitizeFileName(Path.GetFileNameWithoutExtension(file.FileName));
var blobName = $"{companyId}/equipment-manuals/{equipmentId}/{fileName}{extension}";
var contentType = GetContentType(extension);
var contentType = BlobFileHelper.GetContentType(extension);
using var stream = file.OpenReadStream();
var result = await _blobService.UploadAsync(_settings.Containers.Manuals, blobName, stream, contentType);
@@ -130,19 +121,4 @@ public class EquipmentManualService : IEquipmentManualService
return await _blobService.ExistsAsync(_settings.Containers.Manuals, filePath);
}
/// <summary>
/// Maps a lowercase file extension to its canonical MIME content type.
/// Correct MIME types are required so browsers open PDFs inline and
/// Word documents prompt a compatible application rather than a raw download.
/// </summary>
/// <param name="extension">Lowercase file extension including the leading dot.</param>
/// <returns>MIME type string, or <c>application/octet-stream</c> as a safe fallback.</returns>
private static string GetContentType(string extension) => extension switch
{
".pdf" => "application/pdf",
".doc" => "application/msword",
".docx" => "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
".txt" => "text/plain",
_ => "application/octet-stream"
};
}
@@ -0,0 +1,510 @@
using PowderCoating.Application.DTOs.Quote;
using PowderCoating.Application.Interfaces;
using PowderCoating.Core.Entities;
namespace PowderCoating.Application.Services;
/// <summary>
/// Converts quote/job data into persisted <see cref="JobItem"/>, <see cref="JobItemCoat"/>,
/// and <see cref="JobItemPrepService"/> entities.
///
/// Three source types are supported, each with a matching overload:
/// 1. <see cref="CreateQuoteItemDto"/> — quote wizard (new job from form data + fresh pricing result)
/// 2. <see cref="QuoteItem"/> — quote-to-job conversion (copies a saved quote line)
/// 3. <see cref="JobItem"/> — job duplication / template instantiation (copies an existing job line)
///
/// The private <see cref="JobItemSeed"/> / <see cref="JobItemCoatSeed"/> / <see cref="JobItemPrepServiceSeed"/>
/// intermediary classes exist solely to give all three overload paths a single <see cref="BuildJobItem"/>
/// construction site — avoiding subtle copy-paste drift where one overload forgets to copy a new field.
/// </summary>
public class JobItemAssemblyService : IJobItemAssemblyService
{
/// <summary>
/// Creates a <see cref="JobItem"/> from a quote wizard DTO and a pre-calculated pricing result.
/// Used when creating a job directly from the job form or from an approved quote via the wizard.
/// Pricing is passed in separately because it was already computed upstream (CalculateQuoteItemPriceAsync).
/// </summary>
public JobItem CreateJobItem(CreateQuoteItemDto source, int jobId, int companyId, QuoteItemPricingResult pricing, DateTime createdAtUtc)
{
ArgumentNullException.ThrowIfNull(source);
ArgumentNullException.ThrowIfNull(pricing);
return BuildJobItem(
new JobItemSeed
{
Description = source.Description,
Quantity = source.Quantity,
SurfaceAreaSqFt = source.SurfaceAreaSqFt,
CatalogItemId = source.CatalogItemId,
IsGenericItem = source.IsGenericItem,
IsLaborItem = source.IsLaborItem,
IsSalesItem = source.IsSalesItem,
IsAiItem = source.IsAiItem,
Sku = source.Sku,
ManualUnitPrice = source.ManualUnitPrice,
PowderCostOverride = source.PowderCostOverride,
UnitPrice = pricing.UnitPrice,
TotalPrice = pricing.TotalPrice,
LaborCost = pricing.LaborCost,
RequiresSandblasting = source.RequiresSandblasting,
RequiresMasking = source.RequiresMasking,
EstimatedMinutes = source.EstimatedMinutes,
Notes = source.Notes,
IncludePrepCost = source.IncludePrepCost,
Complexity = source.Complexity,
AiTags = source.AiTags,
AiPredictionId = source.AiPredictionId
},
jobId,
companyId,
createdAtUtc);
}
/// <summary>
/// Builds <see cref="JobItemCoat"/> records from the coat DTOs in the quote wizard form.
/// PowderToOrder is recalculated server-side here (not trusted from the form) using surface area,
/// quantity, coverage, and transfer efficiency — the wizard's displayed value is for UI only.
/// </summary>
public IReadOnlyList<JobItemCoat> CreateJobItemCoats(CreateQuoteItemDto source, int jobItemId, int companyId, DateTime createdAtUtc)
{
ArgumentNullException.ThrowIfNull(source);
return source.Coats?
.OrderBy(c => c.Sequence)
.Select(c => BuildJobItemCoat(
new JobItemCoatSeed
{
CoatName = c.CoatName,
Sequence = c.Sequence,
InventoryItemId = c.InventoryItemId,
ColorName = c.ColorName,
VendorId = c.VendorId,
ColorCode = c.ColorCode,
Finish = c.Finish,
CoverageSqFtPerLb = c.CoverageSqFtPerLb,
TransferEfficiency = c.TransferEfficiency,
PowderCostPerLb = c.PowderCostPerLb,
PowderToOrder = CalculatePowderToOrder(c.PowderToOrder, source.SurfaceAreaSqFt, source.Quantity, c.CoverageSqFtPerLb, c.TransferEfficiency),
Notes = c.Notes,
NoExtraLayerCharge = c.NoExtraLayerCharge
},
jobItemId,
companyId,
createdAtUtc))
.ToList() ?? [];
}
/// <summary>
/// Builds <see cref="JobItemPrepService"/> records (sandblasting, masking, etc.) from the
/// quote wizard DTO. These are per-item prep steps with individual time estimates that feed
/// labor cost calculations and shop floor instructions.
/// </summary>
public IReadOnlyList<JobItemPrepService> CreateJobItemPrepServices(CreateQuoteItemDto source, int jobItemId, int companyId, DateTime createdAtUtc)
{
ArgumentNullException.ThrowIfNull(source);
return BuildJobItemPrepServices(
source.PrepServices?.Select(p => new JobItemPrepServiceSeed
{
PrepServiceId = p.PrepServiceId,
EstimatedMinutes = p.EstimatedMinutes,
BlastSetupId = p.BlastSetupId
}),
jobItemId,
companyId,
createdAtUtc);
}
/// <summary>
/// Creates a <see cref="JobItem"/> by copying a saved <see cref="QuoteItem"/> during quote-to-job conversion.
/// Prices are taken directly from the quote snapshot — no repricing occurs — so the job starts with
/// exactly the amounts that were approved by the customer.
/// The first coat's color/finish is promoted to the job item's top-level fields for quick display
/// (details remain in the coat records).
/// </summary>
public JobItem CreateJobItem(QuoteItem source, int jobId, int companyId, DateTime createdAtUtc)
{
ArgumentNullException.ThrowIfNull(source);
var firstCoat = source.Coats?
.OrderBy(c => c.Sequence)
.FirstOrDefault();
return BuildJobItem(
new JobItemSeed
{
Description = source.Description,
Quantity = source.Quantity,
ColorName = firstCoat?.ColorName,
ColorCode = firstCoat?.ColorCode,
Finish = firstCoat?.Finish,
SurfaceArea = source.SurfaceAreaSqFt,
SurfaceAreaSqFt = source.SurfaceAreaSqFt,
CatalogItemId = source.CatalogItemId,
IsGenericItem = source.IsGenericItem,
IsLaborItem = source.IsLaborItem,
IsSalesItem = source.IsSalesItem,
IsAiItem = source.IsAiItem,
Sku = source.Sku,
ManualUnitPrice = source.ManualUnitPrice,
PowderCostOverride = source.PowderCostOverride,
UnitPrice = source.UnitPrice,
TotalPrice = source.TotalPrice,
LaborCost = source.ItemLaborCost,
RequiresSandblasting = source.RequiresSandblasting,
RequiresMasking = source.RequiresMasking,
EstimatedMinutes = source.EstimatedMinutes,
Notes = source.Notes,
IncludePrepCost = source.IncludePrepCost,
Complexity = source.Complexity,
AiTags = source.AiTags,
AiPredictionId = source.AiPredictionId
},
jobId,
companyId,
createdAtUtc);
}
/// <summary>
/// Builds <see cref="JobItemCoat"/> records from a saved <see cref="QuoteItem"/> during quote-to-job conversion.
/// Coat appearance (color name, code, finish) is resolved from the linked <see cref="InventoryItem"/> if available,
/// because the inventory record is the canonical source of truth for a product's appearance —
/// the values typed into the quote form may be incomplete or informal.
/// </summary>
public IReadOnlyList<JobItemCoat> CreateJobItemCoats(QuoteItem source, int jobItemId, int companyId, DateTime createdAtUtc)
{
ArgumentNullException.ThrowIfNull(source);
return source.Coats?
.OrderBy(c => c.Sequence)
.Select(c =>
{
var appearance = ResolveCoatAppearance(c.ColorName, c.ColorCode, c.Finish, c.InventoryItem);
return BuildJobItemCoat(
new JobItemCoatSeed
{
CoatName = c.CoatName,
Sequence = c.Sequence,
InventoryItemId = c.InventoryItemId,
ColorName = appearance.ColorName,
VendorId = c.VendorId,
ColorCode = appearance.ColorCode,
Finish = appearance.Finish,
CoverageSqFtPerLb = c.CoverageSqFtPerLb,
TransferEfficiency = c.TransferEfficiency,
PowderCostPerLb = c.PowderCostPerLb,
PowderToOrder = CalculatePowderToOrder(c.PowderToOrder, source.SurfaceAreaSqFt, source.Quantity, c.CoverageSqFtPerLb, c.TransferEfficiency),
Notes = c.Notes,
NoExtraLayerCharge = c.NoExtraLayerCharge
},
jobItemId,
companyId,
createdAtUtc);
})
.ToList() ?? [];
}
/// <summary>
/// Copies prep service records from a <see cref="QuoteItem"/> to a new job item during quote-to-job conversion.
/// </summary>
public IReadOnlyList<JobItemPrepService> CreateJobItemPrepServices(QuoteItem source, int jobItemId, int companyId, DateTime createdAtUtc)
{
ArgumentNullException.ThrowIfNull(source);
return BuildJobItemPrepServices(
source.PrepServices?.Select(p => new JobItemPrepServiceSeed
{
PrepServiceId = p.PrepServiceId,
EstimatedMinutes = p.EstimatedMinutes,
BlastSetupId = p.BlastSetupId
}),
jobItemId,
companyId,
createdAtUtc);
}
/// <summary>
/// Creates a new <see cref="JobItem"/> by cloning an existing one — used for job templates
/// and rework duplication where an existing job line is reused on a new job.
/// Prices are copied as-is from the source; the job controller is responsible for repricing
/// if operating costs have changed since the original job was created.
/// </summary>
public JobItem CreateJobItem(JobItem source, int jobId, int companyId, DateTime createdAtUtc)
{
ArgumentNullException.ThrowIfNull(source);
return BuildJobItem(
new JobItemSeed
{
Description = source.Description,
Quantity = source.Quantity,
ColorName = source.ColorName,
ColorCode = source.ColorCode,
Finish = source.Finish,
SurfaceArea = source.SurfaceArea,
SurfaceAreaSqFt = source.SurfaceAreaSqFt,
CatalogItemId = source.CatalogItemId,
IsGenericItem = source.IsGenericItem,
IsLaborItem = source.IsLaborItem,
IsSalesItem = source.IsSalesItem,
IsAiItem = source.IsAiItem,
Sku = source.Sku,
ManualUnitPrice = source.ManualUnitPrice,
PowderCostOverride = source.PowderCostOverride,
UnitPrice = source.UnitPrice,
TotalPrice = source.TotalPrice,
LaborCost = source.LaborCost,
RequiresSandblasting = source.RequiresSandblasting,
RequiresMasking = source.RequiresMasking,
EstimatedMinutes = source.EstimatedMinutes,
Notes = source.Notes,
IncludePrepCost = source.IncludePrepCost,
Complexity = source.Complexity,
AiTags = source.AiTags,
AiPredictionId = source.AiPredictionId
},
jobId,
companyId,
createdAtUtc);
}
/// <summary>
/// Clones coat records from an existing <see cref="JobItem"/> onto a new job item.
/// PowderToOrder is copied verbatim (not recalculated) because the original job's powder
/// quantities may have been manually adjusted after initial calculation.
/// </summary>
public IReadOnlyList<JobItemCoat> CreateJobItemCoats(JobItem source, int jobItemId, int companyId, DateTime createdAtUtc)
{
ArgumentNullException.ThrowIfNull(source);
return source.Coats?
.OrderBy(c => c.Sequence)
.Select(c => BuildJobItemCoat(
new JobItemCoatSeed
{
CoatName = c.CoatName,
Sequence = c.Sequence,
InventoryItemId = c.InventoryItemId,
ColorName = c.ColorName,
VendorId = c.VendorId,
ColorCode = c.ColorCode,
Finish = c.Finish,
CoverageSqFtPerLb = c.CoverageSqFtPerLb,
TransferEfficiency = c.TransferEfficiency,
PowderCostPerLb = c.PowderCostPerLb,
PowderToOrder = c.PowderToOrder,
Notes = c.Notes,
NoExtraLayerCharge = c.NoExtraLayerCharge
},
jobItemId,
companyId,
createdAtUtc))
.ToList() ?? [];
}
/// <summary>
/// Clones prep service records from an existing <see cref="JobItem"/> onto a new job item.
/// </summary>
public IReadOnlyList<JobItemPrepService> CreateJobItemPrepServices(JobItem source, int jobItemId, int companyId, DateTime createdAtUtc)
{
ArgumentNullException.ThrowIfNull(source);
return BuildJobItemPrepServices(
source.PrepServices?.Select(p => new JobItemPrepServiceSeed
{
PrepServiceId = p.PrepServiceId,
EstimatedMinutes = p.EstimatedMinutes,
BlastSetupId = p.BlastSetupId
}),
jobItemId,
companyId,
createdAtUtc);
}
/// <summary>
/// Single construction point for all <see cref="JobItem"/> creation paths.
/// Centralised here so that adding a new field only requires one code change, not three.
/// </summary>
private static JobItem BuildJobItem(JobItemSeed seed, int jobId, int companyId, DateTime createdAtUtc)
{
return new JobItem
{
JobId = jobId,
Description = seed.Description,
Quantity = seed.Quantity,
ColorName = seed.ColorName,
ColorCode = seed.ColorCode,
Finish = seed.Finish,
SurfaceArea = seed.SurfaceArea,
SurfaceAreaSqFt = seed.SurfaceAreaSqFt,
CatalogItemId = seed.CatalogItemId,
IsGenericItem = seed.IsGenericItem,
IsLaborItem = seed.IsLaborItem,
IsSalesItem = seed.IsSalesItem,
IsAiItem = seed.IsAiItem,
Sku = seed.Sku,
ManualUnitPrice = seed.ManualUnitPrice,
PowderCostOverride = seed.PowderCostOverride,
UnitPrice = seed.UnitPrice,
TotalPrice = seed.TotalPrice,
LaborCost = seed.LaborCost,
RequiresSandblasting = seed.RequiresSandblasting,
RequiresMasking = seed.RequiresMasking,
EstimatedMinutes = seed.EstimatedMinutes,
Notes = seed.Notes,
IncludePrepCost = seed.IncludePrepCost,
Complexity = seed.Complexity,
AiTags = seed.AiTags,
AiPredictionId = seed.AiPredictionId,
CompanyId = companyId,
CreatedAt = createdAtUtc
};
}
/// <summary>
/// Single construction point for all <see cref="JobItemCoat"/> creation paths.
/// </summary>
private static JobItemCoat BuildJobItemCoat(JobItemCoatSeed seed, int jobItemId, int companyId, DateTime createdAtUtc)
{
return new JobItemCoat
{
JobItemId = jobItemId,
CoatName = seed.CoatName,
Sequence = seed.Sequence,
InventoryItemId = seed.InventoryItemId,
ColorName = seed.ColorName,
VendorId = seed.VendorId,
ColorCode = seed.ColorCode,
Finish = seed.Finish,
CoverageSqFtPerLb = seed.CoverageSqFtPerLb,
TransferEfficiency = seed.TransferEfficiency,
PowderCostPerLb = seed.PowderCostPerLb,
PowderToOrder = seed.PowderToOrder,
Notes = seed.Notes,
NoExtraLayerCharge = seed.NoExtraLayerCharge,
CompanyId = companyId,
CreatedAt = createdAtUtc
};
}
/// <summary>
/// Single construction point for all <see cref="JobItemPrepService"/> creation paths.
/// Returns an empty list (not null) when <paramref name="seeds"/> is null so callers
/// can safely iterate without a null check.
/// </summary>
private static IReadOnlyList<JobItemPrepService> BuildJobItemPrepServices(IEnumerable<JobItemPrepServiceSeed>? seeds, int jobItemId, int companyId, DateTime createdAtUtc)
{
return seeds?
.Select(seed => new JobItemPrepService
{
JobItemId = jobItemId,
PrepServiceId = seed.PrepServiceId,
EstimatedMinutes = seed.EstimatedMinutes,
BlastSetupId = seed.BlastSetupId,
CompanyId = companyId,
CreatedAt = createdAtUtc
})
.ToList() ?? [];
}
/// <summary>
/// Returns the pounds of powder needed to coat a batch, preferring the pre-stored value
/// (which the user may have manually adjusted in the wizard) over a fresh recalculation.
///
/// Formula: (surfaceAreaSqFt × quantity) ÷ (coverageSqFtPerLb × transferEfficiency)
///
/// Industry defaults are applied when catalog data is missing:
/// - Coverage: 30 sqft/lb (typical for standard powder at 23 mil DFT)
/// - Transfer efficiency: 65% (industry average for electrostatic spray)
/// These are conservative defaults that slightly overestimate powder needed — intentional,
/// so the shop doesn't run short on a job.
/// </summary>
private static decimal? CalculatePowderToOrder(decimal? storedPowderToOrder, decimal surfaceAreaSqFt, decimal quantity, decimal coverageSqFtPerLb, decimal transferEfficiency)
{
if (storedPowderToOrder.HasValue && storedPowderToOrder.Value > 0)
return storedPowderToOrder;
if (surfaceAreaSqFt <= 0)
return null;
var coverage = coverageSqFtPerLb > 0 ? coverageSqFtPerLb : 30m;
var efficiency = transferEfficiency > 0 ? transferEfficiency / 100m : 0.65m;
return Math.Round((surfaceAreaSqFt * quantity) / (coverage * efficiency), 2);
}
/// <summary>
/// Resolves the display appearance (color name, code, finish) for a coat, preferring the linked
/// <see cref="InventoryItem"/>'s values over whatever was typed into the quote form.
/// The inventory record is the canonical source of truth — the form values are used as a fallback
/// only when no inventory item is linked (e.g. custom/one-off powder).
/// </summary>
private static (string? ColorName, string? ColorCode, string? Finish) ResolveCoatAppearance(
string? colorName,
string? colorCode,
string? finish,
InventoryItem? inventoryItem)
{
if (inventoryItem == null)
return (colorName, colorCode, finish);
return (inventoryItem.Name, inventoryItem.ColorCode, inventoryItem.Finish);
}
/// <summary>
/// Intermediate value object that normalises the three different source types
/// (DTO, QuoteItem, JobItem) into a single shape before the shared BuildJobItem factory method.
/// Using a seed class prevents subtle bugs where an overload forgets to map a new field.
/// </summary>
private sealed class JobItemSeed
{
public string Description { get; init; } = string.Empty;
public decimal Quantity { get; init; }
public string? ColorName { get; init; }
public string? ColorCode { get; init; }
public string? Finish { get; init; }
public decimal? SurfaceArea { get; init; }
public decimal SurfaceAreaSqFt { get; init; }
public int? CatalogItemId { get; init; }
public bool IsGenericItem { get; init; }
public bool IsLaborItem { get; init; }
public bool IsSalesItem { get; init; }
public bool IsAiItem { get; init; }
public string? Sku { get; init; }
public decimal? ManualUnitPrice { get; init; }
public decimal? PowderCostOverride { get; init; }
public decimal UnitPrice { get; init; }
public decimal TotalPrice { get; init; }
public decimal LaborCost { get; init; }
public bool RequiresSandblasting { get; init; }
public bool RequiresMasking { get; init; }
public int EstimatedMinutes { get; init; }
public string? Notes { get; init; }
public bool IncludePrepCost { get; init; }
public string? Complexity { get; init; }
public string? AiTags { get; init; }
public int? AiPredictionId { get; init; }
}
/// <summary>Intermediate value object for coat creation — see <see cref="JobItemSeed"/> for rationale.</summary>
private sealed class JobItemCoatSeed
{
public string CoatName { get; init; } = string.Empty;
public int Sequence { get; init; }
public int? InventoryItemId { get; init; }
public string? ColorName { get; init; }
public int? VendorId { get; init; }
public string? ColorCode { get; init; }
public string? Finish { get; init; }
public decimal CoverageSqFtPerLb { get; init; }
public decimal TransferEfficiency { get; init; }
public decimal? PowderCostPerLb { get; init; }
public decimal? PowderToOrder { get; init; }
public string? Notes { get; init; }
public bool NoExtraLayerCharge { get; init; }
}
/// <summary>Intermediate value object for prep service creation — see <see cref="JobItemSeed"/> for rationale.</summary>
private sealed class JobItemPrepServiceSeed
{
public int PrepServiceId { get; init; }
public int EstimatedMinutes { get; init; }
public int? BlastSetupId { get; init; }
}
}
@@ -69,19 +69,13 @@ public class JobPhotoService : IJobPhotoService
string? caption = null,
JobPhotoType photoType = JobPhotoType.Progress)
{
if (file == null || file.Length == 0)
return (false, string.Empty, "No file was uploaded.");
if (file.Length > MaxPhotoSize)
return (false, string.Empty, "Photo must be smaller than 10 MB.");
var extension = Path.GetExtension(file.FileName).ToLowerInvariant();
if (string.IsNullOrEmpty(extension) || !AllowedImageTypes.Contains(extension))
return (false, string.Empty, "Only JPG, PNG, GIF, and WebP images are allowed.");
var (isValid, extension, error) = BlobFileHelper.ValidateUpload(file, AllowedImageTypes, MaxPhotoSize);
if (!isValid)
return (false, string.Empty, error);
// SECURITY: Use GUID for blob name to prevent enumeration
var blobName = $"{companyId}/job-photos/{jobId}/{Guid.NewGuid()}{extension}";
var contentType = GetContentType(extension);
var contentType = BlobFileHelper.GetContentType(extension);
using var stream = file.OpenReadStream();
var result = await _blobService.UploadAsync(_settings.Containers.JobImages, blobName, stream, contentType);
@@ -137,19 +131,4 @@ public class JobPhotoService : IJobPhotoService
return await _blobService.ExistsAsync(_settings.Containers.JobImages, filePath);
}
/// <summary>
/// Maps a lowercase file extension to its canonical MIME content type.
/// Falls back to <c>image/jpeg</c> (rather than octet-stream) because all
/// allowed extensions are image types and browsers will render them correctly.
/// </summary>
/// <param name="extension">Lowercase file extension including the leading dot.</param>
/// <returns>MIME type string.</returns>
private static string GetContentType(string extension) => extension switch
{
".jpg" or ".jpeg" => "image/jpeg",
".png" => "image/png",
".gif" => "image/gif",
".webp" => "image/webp",
_ => "image/jpeg"
};
}
@@ -98,7 +98,12 @@ public class PdfService : IPdfService
page.DefaultTextStyle(x => x.FontSize(10).FontFamily("Arial"));
page.Header().Element(c => ComposeInvoiceHeader(c, companyLogo, companyInfo, accentColor, invoiceDto));
page.Content().Element(c => ComposeInvoiceContent(c, invoiceDto, accentColor, template));
page.Content().Layers(layers =>
{
layers.PrimaryLayer().Element(c => ComposeInvoiceContent(c, invoiceDto, accentColor, template));
if (invoiceDto.Status == InvoiceStatus.Paid)
layers.Layer().Element(c => ComposePaidStamp(c));
});
page.Footer().AlignCenter().Text(text =>
{
text.CurrentPageNumber();
@@ -153,7 +158,6 @@ public class PdfService : IPdfService
if (invoice.DueDate.HasValue)
column.Item().Text($"Due: {invoice.DueDate.Value:MMMM d, yyyy}").FontSize(9).FontColor(
invoice.Status == Core.Enums.InvoiceStatus.Overdue ? Colors.Red.Medium : Colors.Grey.Darken2);
column.Item().Text($"Status: {invoice.Status}").FontSize(9);
});
});
@@ -161,6 +165,27 @@ public class PdfService : IPdfService
});
}
/// <summary>
/// Renders a semi-transparent angled PAID stamp centred over the invoice content layer.
/// Uses QuestPDF layout primitives (AlignCenter, AlignMiddle, Rotate, Opacity) so no
/// external Skia/SkiaSharp dependency is needed.
/// </summary>
private static void ComposePaidStamp(IContainer container)
{
container
.AlignCenter()
.AlignMiddle()
.Rotate(-45f)
.Border(5)
.BorderColor(Colors.Green.Darken2)
.PaddingVertical(14)
.PaddingHorizontal(28)
.Text("PAID")
.FontSize(80)
.Bold()
.FontColor(Colors.Green.Darken2);
}
/// <summary>
/// Composes the body of the invoice PDF: bill-to address block, job reference, alternating-row
/// line-item table, and a right-aligned totals block that conditionally shows discount, tax,
@@ -640,12 +665,13 @@ public class PdfService : IPdfService
{
column.Item().PaddingTop(10).Table(table =>
{
// Define columns: Description, Color, Qty, Total
// Define columns: Description, Color, Qty, Unit Price, Total
table.ColumnsDefinition(columns =>
{
columns.RelativeColumn(4); // Description
columns.RelativeColumn(2); // Color
columns.ConstantColumn(40); // Qty
columns.ConstantColumn(80); // Unit Price
columns.ConstantColumn(80); // Total
});
@@ -655,6 +681,7 @@ public class PdfService : IPdfService
header.Cell().Background(accentColor).Padding(5).Text("Description").FontSize(9).Bold().FontColor(Colors.White);
header.Cell().Background(accentColor).Padding(5).Text("Color").FontSize(9).Bold().FontColor(Colors.White);
header.Cell().Background(accentColor).Padding(5).Text("Qty").FontSize(9).Bold().FontColor(Colors.White);
header.Cell().Background(accentColor).Padding(5).AlignRight().Text("Unit Price").FontSize(9).Bold().FontColor(Colors.White);
header.Cell().Background(accentColor).Padding(5).AlignRight().Text("Total").FontSize(9).Bold().FontColor(Colors.White);
});
@@ -757,6 +784,9 @@ public class PdfService : IPdfService
// Quantity (centered)
table.Cell().Background(bgColor).Padding(5).AlignCenter().Text(item.Quantity.ToString()).FontSize(9);
// Unit Price (right-aligned)
table.Cell().Background(bgColor).Padding(5).AlignRight().Text($"${item.UnitPrice:N2}").FontSize(9);
// Total (right-aligned, bold)
table.Cell().Background(bgColor).Padding(5).AlignRight().Text($"${item.TotalPrice:N2}").FontSize(9).Bold();
@@ -1828,6 +1858,50 @@ public class PdfService : IPdfService
});
}
/// <summary>
/// Generates a multi-page PDF containing one gift certificate per page, all using the same
/// branded layout as the single-certificate download. Used for bulk print runs (car shows,
/// promotions) so staff can hand-cut and distribute a full batch from one print job.
/// </summary>
public async Task<byte[]> GenerateBulkGiftCertificatePdfAsync(
IList<GiftCertificateDto> certs,
byte[]? companyLogo,
string? companyLogoContentType,
CompanyInfoDto companyInfo)
{
QuestPDF.Settings.License = LicenseType.Community;
const string accent = "#7c3aed";
const string gold = "#b45309";
return await Task.Run(() =>
{
var doc = Document.Create(container =>
{
foreach (var cert in certs)
{
container.Page(page =>
{
page.Size(PageSizes.Letter);
page.Margin(0.75f, Unit.Inch);
page.PageColor(Colors.White);
page.DefaultTextStyle(x => x.FontSize(10).FontFamily("Arial"));
page.Content().Element(c => ComposeGiftCertificateContent(c, cert, companyInfo, companyLogo, accent, gold));
page.Footer().AlignCenter().Text(text =>
{
text.Span(companyInfo.CompanyName).FontSize(8).FontColor(Colors.Grey.Darken1);
if (!string.IsNullOrWhiteSpace(companyInfo.Phone))
text.Span($" · {FormatPhoneNumber(companyInfo.Phone)}").FontSize(8).FontColor(Colors.Grey.Darken1);
});
});
}
});
return doc.GeneratePdf();
});
}
/// <summary>
/// Composes the gift certificate body with a decorative double-border frame (outer purple 3pt,
/// inner gold 1pt) that gives the document a premium printed-certificate appearance. Inside the
@@ -2327,4 +2401,356 @@ public class PdfService : IPdfService
return document.GeneratePdf();
});
}
/// <summary>
/// Generates an Accounts Payable Aging PDF. Layout mirrors GenerateArAgingPdfAsync:
/// a KPI summary band, a per-vendor summary table with aging columns, then a bill-detail
/// section grouped by vendor. Uses a red accent palette to visually distinguish AP from AR.
/// </summary>
public async Task<byte[]> GenerateApAgingPdfAsync(ApAgingReportDto dto)
{
QuestPDF.Settings.License = LicenseType.Community;
const string accent = "#b91c1c";
return await Task.Run(() =>
{
var document = Document.Create(container =>
{
container.Page(page =>
{
page.Size(PageSizes.Letter);
page.Margin(0.6f, Unit.Inch);
page.PageColor(Colors.White);
page.DefaultTextStyle(x => x.FontSize(9).FontFamily("Arial"));
page.Header().Element(c => ComposeReportHeader(c, dto.CompanyName, "Accounts Payable Aging",
$"As of {dto.AsOf:MMMM d, yyyy}", accent));
page.Content().PaddingTop(12).Column(col =>
{
col.Item().Background("#f8fafc").Border(1).BorderColor("#e2e8f0").Padding(8).Row(row =>
{
KpiCell(row, "Current", dto.TotalCurrent.ToString("C0"), "#16a34a");
KpiCell(row, "130 Days", dto.Total1to30.ToString("C0"), "#ca8a04");
KpiCell(row, "3160 Days", dto.Total31to60.ToString("C0"), "#ea580c");
KpiCell(row, "6190 Days", dto.Total61to90.ToString("C0"), "#dc2626");
KpiCell(row, "Over 90", dto.TotalOver90.ToString("C0"), "#7f1d1d");
KpiCell(row, "Total Owed", dto.TotalOutstanding.ToString("C0"), accent);
});
if (!dto.Vendors.Any())
{
col.Item().PaddingTop(20).AlignCenter()
.Text("All bills are paid — no outstanding balances.")
.FontSize(11).FontColor("#16a34a");
return;
}
col.Item().PaddingTop(14).Table(table =>
{
table.ColumnsDefinition(cols =>
{
cols.RelativeColumn(3);
cols.RelativeColumn(2);
cols.RelativeColumn(2);
cols.RelativeColumn(2);
cols.RelativeColumn(2);
cols.RelativeColumn(2);
cols.RelativeColumn(2);
});
table.Header(h =>
{
foreach (var lbl in new[] { "Vendor", "Current", "130", "3160", "6190", "Over 90", "Total" })
h.Cell().Background(accent).Padding(4).Text(lbl).FontColor(Colors.White).Bold().FontSize(8);
});
var alt = false;
foreach (var vend in dto.Vendors)
{
var bg = alt ? "#f8fafc" : "#ffffff";
table.Cell().Background(bg).Padding(4).Text(vend.VendorName).FontSize(9).Bold();
table.Cell().Background(bg).AlignRight().Padding(4).Text(vend.TotalCurrent > 0 ? vend.TotalCurrent.ToString("C") : "—").FontSize(9).FontColor("#16a34a");
table.Cell().Background(bg).AlignRight().Padding(4).Text(vend.Total1to30 > 0 ? vend.Total1to30.ToString("C") : "—").FontSize(9).FontColor("#ca8a04");
table.Cell().Background(bg).AlignRight().Padding(4).Text(vend.Total31to60 > 0 ? vend.Total31to60.ToString("C") : "—").FontSize(9).FontColor("#ea580c");
table.Cell().Background(bg).AlignRight().Padding(4).Text(vend.Total61to90 > 0 ? vend.Total61to90.ToString("C") : "—").FontSize(9).FontColor("#dc2626");
table.Cell().Background(bg).AlignRight().Padding(4).Text(vend.TotalOver90 > 0 ? vend.TotalOver90.ToString("C") : "—").FontSize(9).FontColor("#7f1d1d");
table.Cell().Background(bg).AlignRight().Padding(4).Text(vend.TotalBalance.ToString("C")).FontSize(9).Bold();
alt = !alt;
}
table.Cell().Background("#e2e8f0").Padding(4).Text("Total").FontSize(9).Bold();
table.Cell().Background("#e2e8f0").AlignRight().Padding(4).Text(dto.TotalCurrent.ToString("C")).FontSize(9).Bold().FontColor("#16a34a");
table.Cell().Background("#e2e8f0").AlignRight().Padding(4).Text(dto.Total1to30.ToString("C")).FontSize(9).Bold().FontColor("#ca8a04");
table.Cell().Background("#e2e8f0").AlignRight().Padding(4).Text(dto.Total31to60.ToString("C")).FontSize(9).Bold().FontColor("#ea580c");
table.Cell().Background("#e2e8f0").AlignRight().Padding(4).Text(dto.Total61to90.ToString("C")).FontSize(9).Bold().FontColor("#dc2626");
table.Cell().Background("#e2e8f0").AlignRight().Padding(4).Text(dto.TotalOver90.ToString("C")).FontSize(9).Bold().FontColor("#7f1d1d");
table.Cell().Background("#e2e8f0").AlignRight().Padding(4).Text(dto.TotalOutstanding.ToString("C")).FontSize(9).Bold();
});
col.Item().PaddingTop(16).Text("Bill Detail").FontSize(11).Bold();
foreach (var vend in dto.Vendors)
{
col.Item().PaddingTop(8).ShowEntire().Column(vendCol =>
{
vendCol.Item().Background("#f1f5f9").Padding(4).Text(vend.VendorName).Bold().FontSize(10);
vendCol.Item().Table(table =>
{
table.ColumnsDefinition(cols =>
{
cols.RelativeColumn(2);
cols.RelativeColumn(2);
cols.RelativeColumn(2);
cols.RelativeColumn(2);
cols.RelativeColumn(2);
});
table.Header(h =>
{
foreach (var lbl in new[] { "Bill #", "Bill Date", "Due Date", "Balance", "Age" })
h.Cell().Background("#e2e8f0").Padding(3).Text(lbl).Bold().FontSize(8);
});
foreach (var bill in vend.Bills.OrderBy(b => b.DaysOverdue))
{
var ageColor = bill.DaysOverdue <= 0 ? "#16a34a"
: bill.DaysOverdue <= 30 ? "#ca8a04"
: bill.DaysOverdue <= 60 ? "#ea580c"
: bill.DaysOverdue <= 90 ? "#dc2626"
: "#7f1d1d";
var ageLabel = bill.DaysOverdue <= 0 ? "Current" : $"{bill.DaysOverdue}d overdue";
table.Cell().Padding(3).Text(bill.BillNumber).FontSize(8);
table.Cell().Padding(3).Text(bill.BillDate.ToString("MM/dd/yyyy")).FontSize(8).FontColor(Colors.Grey.Darken1);
table.Cell().Padding(3).Text(bill.DueDate?.ToString("MM/dd/yyyy") ?? "—").FontSize(8).FontColor(Colors.Grey.Darken1);
table.Cell().AlignRight().Padding(3).Text(bill.BalanceDue.ToString("C")).Bold().FontSize(8)
.FontColor(bill.DaysOverdue > 30 ? "#dc2626" : "#000000");
table.Cell().Padding(3).Text(ageLabel).FontSize(8).FontColor(ageColor);
}
table.Cell().ColumnSpan(3).Background("#f1f5f9").AlignRight().Padding(3)
.Text($"{vend.VendorName} subtotal").Bold().FontSize(8).FontColor(Colors.Grey.Darken2);
table.Cell().Background("#f1f5f9").AlignRight().Padding(3).Text(vend.TotalBalance.ToString("C")).Bold().FontSize(8);
table.Cell().Background("#f1f5f9");
});
});
}
});
page.Footer().AlignCenter().Text(text =>
{
text.CurrentPageNumber(); text.Span(" / "); text.TotalPages();
text.Span($" · {dto.CompanyName} · Generated {DateTime.Now:MMM d, yyyy}").FontSize(8).FontColor(Colors.Grey.Darken1);
});
});
});
return document.GeneratePdf();
});
}
/// <summary>
/// Generates a Trial Balance PDF. Each active account appears once with its balance in either
/// the Debit or Credit column based on AccountingRules sign conventions. A footer row shows
/// totals and a balanced/unbalanced indicator.
/// </summary>
public async Task<byte[]> GenerateTrialBalancePdfAsync(TrialBalanceDto dto)
{
QuestPDF.Settings.License = LicenseType.Community;
const string accent = "#1a56db";
return await Task.Run(() =>
{
var document = Document.Create(container =>
{
container.Page(page =>
{
page.Size(PageSizes.Letter);
page.Margin(0.6f, Unit.Inch);
page.PageColor(Colors.White);
page.DefaultTextStyle(x => x.FontSize(9).FontFamily("Arial"));
page.Header().Element(c => ComposeReportHeader(c, dto.CompanyName, "Trial Balance",
$"As of {dto.AsOf:MMMM d, yyyy}", accent));
page.Content().PaddingTop(12).Column(col =>
{
col.Item().Background("#f8fafc").Border(1).BorderColor("#e2e8f0").Padding(8).Row(row =>
{
KpiCell(row, "Total Debits", dto.TotalDebits.ToString("C0"), "#1a56db");
KpiCell(row, "Total Credits", dto.TotalCredits.ToString("C0"), "#1a56db");
KpiCell(row, "Status", dto.IsBalanced ? "Balanced ✓" : "Out of Balance ✗",
dto.IsBalanced ? "#16a34a" : "#dc2626");
});
if (!dto.Lines.Any())
{
col.Item().PaddingTop(20).AlignCenter()
.Text("No active accounts with balances found.")
.FontSize(11).FontColor(Colors.Grey.Darken1);
return;
}
col.Item().PaddingTop(14).Table(table =>
{
table.ColumnsDefinition(cols =>
{
cols.ConstantColumn(70);
cols.RelativeColumn(4);
cols.RelativeColumn(2);
cols.RelativeColumn(2);
cols.RelativeColumn(2);
});
table.Header(h =>
{
foreach (var lbl in new[] { "Acct #", "Account Name", "Type", "Debit", "Credit" })
h.Cell().Background(accent).Padding(4).Text(lbl).FontColor(Colors.White).Bold().FontSize(8);
});
var alt = false;
foreach (var line in dto.Lines)
{
var bg = alt ? "#f8fafc" : "#ffffff";
table.Cell().Background(bg).Padding(4).Text(line.AccountNumber).FontSize(8).FontColor(Colors.Grey.Darken2);
table.Cell().Background(bg).Padding(4).Text(line.AccountName).FontSize(9);
table.Cell().Background(bg).Padding(4).Text(line.AccountType.ToString()).FontSize(8).FontColor(Colors.Grey.Darken1);
table.Cell().Background(bg).AlignRight().Padding(4).Text(line.DebitBalance > 0 ? line.DebitBalance.ToString("C") : "").FontSize(9);
table.Cell().Background(bg).AlignRight().Padding(4).Text(line.CreditBalance > 0 ? line.CreditBalance.ToString("C") : "").FontSize(9);
alt = !alt;
}
table.Cell().ColumnSpan(3).Background("#e2e8f0").Padding(4).Text("Total").FontSize(9).Bold();
table.Cell().Background("#e2e8f0").AlignRight().Padding(4).Text(dto.TotalDebits.ToString("C")).FontSize(9).Bold();
table.Cell().Background("#e2e8f0").AlignRight().Padding(4).Text(dto.TotalCredits.ToString("C")).FontSize(9).Bold();
});
});
page.Footer().AlignCenter().Text(text =>
{
text.CurrentPageNumber(); text.Span(" / "); text.TotalPages();
text.Span($" · {dto.CompanyName} · Generated {DateTime.Now:MMM d, yyyy}").FontSize(8).FontColor(Colors.Grey.Darken1);
});
});
});
return document.GeneratePdf();
});
}
/// <summary>
/// Generates a Cash Flow Statement PDF with three sections (Operating, Investing, Financing)
/// plus a summary reconciling beginning → ending cash. Uses a teal accent palette to
/// visually distinguish it from the other financial statements.
/// </summary>
public async Task<byte[]> GenerateCashFlowStatementPdfAsync(CashFlowStatementDto dto)
{
QuestPDF.Settings.License = LicenseType.Community;
const string accent = "#0891b2";
return await Task.Run(() =>
{
var document = Document.Create(container =>
{
container.Page(page =>
{
page.Size(PageSizes.Letter);
page.Margin(0.6f, Unit.Inch);
page.PageColor(Colors.White);
page.DefaultTextStyle(x => x.FontSize(9).FontFamily("Arial"));
page.Header().Element(c => ComposeReportHeader(c, dto.CompanyName, "Cash Flow Statement",
$"{dto.From:MMMM d, yyyy} {dto.To:MMMM d, yyyy}", accent));
page.Content().PaddingTop(12).Column(col =>
{
col.Spacing(4);
// ── Operating Activities ──────────────────────────────────────
col.Item().Text("Operating Activities").Bold().FontSize(11).FontColor(accent);
col.Item().Table(t =>
{
t.ColumnsDefinition(c => { c.RelativeColumn(3); c.ConstantColumn(100); });
CfRow(t, "Cash Received from Customers", dto.CashFromCustomers, false);
CfRow(t, "Cash Paid to Vendors (Bills)", -dto.CashToVendors, false);
CfRow(t, "Cash Paid for Expenses", -dto.CashForExpenses, false);
CfTotalRow(t, "Net Cash from Operating Activities", dto.NetOperating);
});
col.Item().PaddingTop(10).Text("Investing Activities").Bold().FontSize(11).FontColor(accent);
col.Item().Table(t =>
{
t.ColumnsDefinition(c => { c.RelativeColumn(3); c.ConstantColumn(100); });
if (dto.InvestingLines.Count == 0)
CfRow(t, "No investing activities recorded", 0, true);
else
foreach (var line in dto.InvestingLines)
CfRow(t, line.Label, line.Amount, false);
CfTotalRow(t, "Net Cash from Investing Activities", dto.NetInvesting);
});
col.Item().PaddingTop(10).Text("Financing Activities").Bold().FontSize(11).FontColor(accent);
col.Item().Table(t =>
{
t.ColumnsDefinition(c => { c.RelativeColumn(3); c.ConstantColumn(100); });
if (dto.FinancingLines.Count == 0)
CfRow(t, "No financing activities recorded", 0, true);
else
foreach (var line in dto.FinancingLines)
CfRow(t, line.Label, line.Amount, false);
CfTotalRow(t, "Net Cash from Financing Activities", dto.NetFinancing);
});
// ── Summary ───────────────────────────────────────────────────
col.Item().PaddingTop(12).Table(t =>
{
t.ColumnsDefinition(c => { c.RelativeColumn(3); c.ConstantColumn(100); });
void SumRow(string label, decimal amount, bool bold = false)
{
var bg = bold ? "#e0f2fe" : "#ffffff";
var lText = t.Cell().Background(bg).PaddingVertical(4).PaddingHorizontal(6).Text(label).FontSize(9);
if (bold) lText.Bold();
var vText = t.Cell().Background(bg).PaddingVertical(4).PaddingHorizontal(6).AlignRight()
.Text(amount.ToString("C")).FontSize(9)
.FontColor(amount < 0 ? Colors.Red.Darken2 : Colors.Black);
if (bold) vText.Bold();
}
SumRow("Beginning Cash Balance", dto.BeginningCash);
SumRow("Net Change in Cash", dto.NetChangeInCash);
SumRow("Ending Cash Balance", dto.EndingCash, bold: true);
});
});
page.Footer().AlignCenter().Text(text =>
{
text.CurrentPageNumber(); text.Span(" / "); text.TotalPages();
text.Span($" · {dto.CompanyName} · Generated {DateTime.Now:MMM d, yyyy}").FontSize(8).FontColor(Colors.Grey.Darken1);
});
});
});
return document.GeneratePdf();
});
static void CfRow(TableDescriptor t, string label, decimal amount, bool muted)
{
t.Cell().BorderBottom(0.5f).BorderColor("#e5e7eb")
.PaddingVertical(3).PaddingHorizontal(6)
.Text(label).FontSize(9).FontColor(muted ? Colors.Grey.Medium : Colors.Black);
t.Cell().BorderBottom(0.5f).BorderColor("#e5e7eb")
.PaddingVertical(3).PaddingHorizontal(6).AlignRight()
.Text(muted ? "" : amount.ToString("C")).FontSize(9)
.FontColor(amount < 0 ? Colors.Red.Darken2 : Colors.Black);
}
static void CfTotalRow(TableDescriptor t, string label, decimal amount)
{
t.Cell().Background("#f0f9ff").PaddingVertical(4).PaddingHorizontal(6)
.Text(label).Bold().FontSize(9);
t.Cell().Background("#f0f9ff").PaddingVertical(4).PaddingHorizontal(6).AlignRight()
.Text(amount.ToString("C")).Bold().FontSize(9)
.FontColor(amount < 0 ? Colors.Red.Darken2 : Colors.Black);
}
}
}
@@ -126,9 +126,11 @@ public class PricingCalculationService : IPricingCalculationService
// A coat is "custom" (must be purchased) when it has no inventory item but has a manual price.
// In-stock coats reference an inventory item that already has stock on hand.
// Incoming coats reference an inventory item with IsIncoming=true (ordered, not yet received).
bool isCustomPowder = !coat.InventoryItemId.HasValue
&& coat.PowderCostPerLb.HasValue
&& coat.PowderCostPerLb.Value > 0;
bool isIncomingPowder = false;
if (coat.PowderCostPerLb.HasValue && coat.PowderCostPerLb.Value > 0)
{
@@ -143,13 +145,14 @@ public class PricingCalculationService : IPricingCalculationService
}
else if (coat.InventoryItemId.HasValue && coat.InventoryItemId.Value > 0)
{
// In-stock powder - use inventory cost
// In-stock or incoming powder - use inventory cost
try
{
var inventoryItem = await _unitOfWork.InventoryItems.GetByIdAsync(coat.InventoryItemId.Value);
if (inventoryItem != null && inventoryItem.UnitCost > 0)
{
costPerLb = inventoryItem.UnitCost;
isIncomingPowder = inventoryItem.IsIncoming;
var coverage = coat.CoverageSqFtPerLb;
var transferEfficiency = coat.TransferEfficiency;
@@ -157,8 +160,8 @@ public class PricingCalculationService : IPricingCalculationService
var actualPoundsPerSqFt = poundsPerSqFt / (transferEfficiency / 100m);
powderCostPerSqFt = actualPoundsPerSqFt * costPerLb;
_logger.LogInformation("Coat {CoatName}: Using inventory item: {InventoryItem}, UnitCost={UnitCost}/lb, Coverage={Coverage}sqft/lb, Efficiency={Efficiency}%, Calculated={CalcCost}/sqft",
coat.CoatName, inventoryItem.Name, inventoryItem.UnitCost, coverage, transferEfficiency, powderCostPerSqFt);
_logger.LogInformation("Coat {CoatName}: Using inventory item: {InventoryItem} (IsIncoming={IsIncoming}), UnitCost={UnitCost}/lb, Coverage={Coverage}sqft/lb, Efficiency={Efficiency}%, Calculated={CalcCost}/sqft",
coat.CoatName, inventoryItem.Name, isIncomingPowder, inventoryItem.UnitCost, coverage, transferEfficiency, powderCostPerSqFt);
}
}
catch (Exception ex)
@@ -172,13 +175,13 @@ public class PricingCalculationService : IPricingCalculationService
var batchSurfaceAreaSqFt = perItemSurfaceAreaSqFt * quantity;
decimal coatMaterialCost;
if (batchSurfaceAreaSqFt > 0 && isCustomPowder && coat.PowderToOrder.HasValue && coat.PowderToOrder.Value > 0)
// Custom or incoming powder must be purchased for this job — charge for the full ordered
// quantity so the shop recovers the actual outlay, not just the calculated usage.
if (batchSurfaceAreaSqFt > 0 && (isCustomPowder || isIncomingPowder) && coat.PowderToOrder.HasValue && coat.PowderToOrder.Value > 0)
{
// Custom powder that must be purchased: charge for the full ordered quantity, not just
// the calculated usage. The shop is spending money on the entire order for this job.
coatMaterialCost = coat.PowderToOrder.Value * costPerLb;
_logger.LogInformation("Coat {CoatName}: Custom powder to order — charging full order qty {Lbs}lb × ${CostPerLb}/lb = ${Total} (calculated usage would have been ${Calc})",
coat.CoatName, coat.PowderToOrder.Value, costPerLb, coatMaterialCost, batchSurfaceAreaSqFt * powderCostPerSqFt);
_logger.LogInformation("Coat {CoatName}: {PowderKind} powder to order — charging full order qty {Lbs}lb × ${CostPerLb}/lb = ${Total} (calculated usage would have been ${Calc})",
coat.CoatName, isIncomingPowder ? "Incoming" : "Custom", coat.PowderToOrder.Value, costPerLb, coatMaterialCost, batchSurfaceAreaSqFt * powderCostPerSqFt);
}
else if (batchSurfaceAreaSqFt > 0)
{
@@ -587,53 +590,9 @@ public class PricingCalculationService : IPricingCalculationService
{
QuoteItemPricingResult itemResult;
// Catalog items - if they have coats, add coat costs to catalog base price
if (item.CatalogItemId.HasValue)
{
var catalogItem = await _unitOfWork.CatalogItems.GetByIdAsync(item.CatalogItemId.Value);
if (catalogItem != null)
{
// If the catalog item has coats, calculate using CalculateQuoteItemPriceAsync
// (which already includes the catalog base price + coat costs)
if (item.Coats != null && item.Coats.Any())
{
// CalculateQuoteItemPriceAsync already adds catalog base price to coat costs
itemResult = await CalculateQuoteItemPriceAsync(item, companyId, ovenCostOverride);
}
else
{
// No coats - use simple catalog default price
var catalogItemTotal = catalogItem.DefaultPrice * item.Quantity;
itemResult = new QuoteItemPricingResult
{
MaterialCost = 0,
LaborCost = 0,
EquipmentCost = 0,
ItemSubtotal = catalogItemTotal,
UnitPrice = catalogItem.DefaultPrice,
TotalPrice = catalogItemTotal
};
}
}
else
{
// Catalog item not found, create zero result
itemResult = new QuoteItemPricingResult
{
MaterialCost = 0,
LaborCost = 0,
EquipmentCost = 0,
ItemSubtotal = 0,
UnitPrice = 0,
TotalPrice = 0
};
}
}
else
{
// Calculated items use the full pricing calculation
itemResult = await CalculateQuoteItemPriceAsync(item, companyId, ovenCostOverride);
}
// All items (catalog and calculated) go through CalculateQuoteItemPriceAsync, which
// handles PowderCostOverride, prep cost inclusion, and all item type variants.
itemResult = await CalculateQuoteItemPriceAsync(item, companyId, ovenCostOverride);
itemResults.Add(itemResult);
}
@@ -66,22 +66,16 @@ public class ProfilePhotoService : IProfilePhotoService
string userId,
int companyId)
{
if (file == null || file.Length == 0)
return (false, string.Empty, "No file was uploaded.");
if (file.Length > MaxPhotoSize)
return (false, string.Empty, "Photo must be smaller than 10 MB.");
var extension = Path.GetExtension(file.FileName).ToLowerInvariant();
if (string.IsNullOrEmpty(extension) || !AllowedImageTypes.Contains(extension))
return (false, string.Empty, "Only JPG, PNG, GIF, and WebP images are allowed.");
var (isValid, extension, error) = BlobFileHelper.ValidateUpload(file, AllowedImageTypes, MaxPhotoSize);
if (!isValid)
return (false, string.Empty, error);
// Delete old photos for this user with different extensions
await DeleteOldPhotosForUserAsync(companyId, userId, extension);
// Blob path mirrors former filesystem path
var blobName = $"{companyId}/profile-photos/{userId}{extension}";
var contentType = GetContentType(extension);
var contentType = BlobFileHelper.GetContentType(extension);
using var stream = file.OpenReadStream();
var result = await _blobService.UploadAsync(_settings.Containers.ProfileImages, blobName, stream, contentType);
@@ -172,19 +166,4 @@ public class ProfilePhotoService : IProfilePhotoService
}
}
/// <summary>
/// Maps a lowercase file extension to its canonical MIME content type.
/// Falls back to <c>image/jpeg</c> (rather than octet-stream) because all
/// allowed extensions are image types and browsers will render them correctly.
/// </summary>
/// <param name="extension">Lowercase file extension including the leading dot.</param>
/// <returns>MIME type string.</returns>
private static string GetContentType(string extension) => extension switch
{
".jpg" or ".jpeg" => "image/jpeg",
".png" => "image/png",
".gif" => "image/gif",
".webp" => "image/webp",
_ => "image/jpeg"
};
}
@@ -50,19 +50,13 @@ public class QuotePhotoService : IQuotePhotoService
public async Task<(bool Success, string TempId, string FilePath, string ErrorMessage)> SaveTempPhotoAsync(
IFormFile file, int companyId)
{
if (file == null || file.Length == 0)
return (false, string.Empty, string.Empty, "No file provided.");
if (file.Length > MaxFileSizeBytes)
return (false, string.Empty, string.Empty, "File exceeds the 10 MB limit.");
var ext = Path.GetExtension(file.FileName).ToLowerInvariant();
if (!AllowedExtensions.Contains(ext))
return (false, string.Empty, string.Empty, $"File type '{ext}' is not allowed.");
var (isValid, ext, validationError) = BlobFileHelper.ValidateUpload(file, AllowedExtensions, MaxFileSizeBytes);
if (!isValid)
return (false, string.Empty, string.Empty, validationError);
var tempId = Guid.NewGuid().ToString("N");
var blobName = $"temp/{tempId}/{Guid.NewGuid():N}{ext}";
var contentType = GetContentType(ext);
var contentType = BlobFileHelper.GetContentType(ext);
using var stream = file.OpenReadStream();
var result = await _blobService.UploadAsync(_settings.Containers.QuoteImages, blobName, stream, contentType);
@@ -100,7 +94,7 @@ public class QuotePhotoService : IQuotePhotoService
return (false, string.Empty, "Failed to read temp photo.");
using var ms = new MemoryStream(download.Content);
var upload = await _blobService.UploadAsync(_settings.Containers.QuoteImages, destBlob, ms, GetContentType(ext));
var upload = await _blobService.UploadAsync(_settings.Containers.QuoteImages, destBlob, ms, BlobFileHelper.GetContentType(ext));
if (!upload.Success)
return (false, string.Empty, "Failed to save permanent photo.");
@@ -173,12 +167,4 @@ public class QuotePhotoService : IQuotePhotoService
}
}
private static string GetContentType(string ext) => ext switch
{
".jpg" or ".jpeg" => "image/jpeg",
".png" => "image/png",
".gif" => "image/gif",
".webp" => "image/webp",
_ => "image/jpeg"
};
}
@@ -0,0 +1,452 @@
using PowderCoating.Application.DTOs.Quote;
using PowderCoating.Application.Interfaces;
using PowderCoating.Core.Entities;
using PowderCoating.Core.Interfaces;
using Microsoft.Extensions.Logging;
namespace PowderCoating.Application.Services;
/// <summary>
/// Orchestrates the full quote item assembly pipeline: pricing calculation, entity construction,
/// AI prediction tracking, and automatic inventory record creation for incoming powder orders.
///
/// This service sits above <see cref="PricingCalculationService"/> — it knows HOW to build and
/// persist quote entities, while PricingCalculationService knows HOW to compute dollar amounts.
/// Keeping them separate means pricing logic can be unit-tested without any entity construction concerns.
///
/// Key responsibilities:
/// - <see cref="ApplyPricingSnapshot"/> — stamps calculated totals onto the Quote entity so the
/// displayed price is frozen at quote time and won't change if operating costs are updated later.
/// - <see cref="CreateQuoteItemsAsync"/> — builds QuoteItem + coats + prep services for each DTO,
/// records AI prediction overrides, and auto-creates incoming inventory records when needed.
/// </summary>
public class QuotePricingAssemblyService : IQuotePricingAssemblyService
{
private readonly IUnitOfWork _unitOfWork;
private readonly IPricingCalculationService _pricingService;
private readonly IInventoryAiLookupService _aiLookupService;
private readonly ILogger<QuotePricingAssemblyService> _logger;
public QuotePricingAssemblyService(
IUnitOfWork unitOfWork,
IPricingCalculationService pricingService,
IInventoryAiLookupService aiLookupService,
ILogger<QuotePricingAssemblyService> logger)
{
_unitOfWork = unitOfWork;
_pricingService = pricingService;
_aiLookupService = aiLookupService;
_logger = logger;
}
/// <summary>
/// Writes the calculated pricing breakdown onto the <see cref="Quote"/> entity as a snapshot.
/// Snapshots are critical: once a quote is sent to a customer, operating cost changes must NOT
/// silently alter the quoted amounts — the snapshot preserves what was presented at the time.
/// </summary>
public void ApplyPricingSnapshot(Quote quote, QuotePricingResult pricingResult)
{
ArgumentNullException.ThrowIfNull(quote);
ArgumentNullException.ThrowIfNull(pricingResult);
quote.MaterialCosts = pricingResult.MaterialCosts;
quote.LaborCosts = pricingResult.LaborCosts;
quote.EquipmentCosts = pricingResult.EquipmentCosts;
quote.ItemsSubtotal = pricingResult.ItemsSubtotal;
quote.OvenBatchCost = pricingResult.OvenBatchCost;
quote.FacilityOverheadCost = pricingResult.FacilityOverheadCost;
quote.FacilityOverheadRatePerHour = pricingResult.FacilityOverheadRatePerHour;
quote.ShopSuppliesAmount = pricingResult.ShopSuppliesAmount;
quote.ShopSuppliesPercent = pricingResult.ShopSuppliesPercent;
quote.OverheadAmount = pricingResult.OverheadCosts;
quote.OverheadPercent = pricingResult.OverheadPercent;
quote.ProfitMargin = pricingResult.ProfitMargin;
quote.ProfitPercent = pricingResult.ProfitPercent;
quote.SubTotal = pricingResult.SubtotalBeforeDiscount;
quote.PricingTierDiscountAmount = pricingResult.PricingTierDiscountAmount;
quote.PricingTierDiscountPercent = pricingResult.PricingTierDiscountPercent;
quote.QuoteDiscountAmount = pricingResult.QuoteDiscountAmount;
quote.QuoteDiscountPercent = pricingResult.QuoteDiscountPercent;
quote.DiscountPercent = pricingResult.DiscountPercent;
quote.DiscountAmount = pricingResult.DiscountAmount;
quote.SubtotalAfterDiscount = pricingResult.SubtotalAfterDiscount;
quote.RushFee = pricingResult.RushFee;
quote.TaxAmount = pricingResult.TaxAmount;
quote.Total = pricingResult.Total;
}
/// <summary>
/// Builds and prices all <see cref="QuoteItem"/> entities from the incoming DTOs.
/// For each item: constructs the entity, calculates pricing, records whether the user overrode
/// an AI estimate, then attaches coats (including auto-creating incoming inventory entries when
/// the user selects a catalog powder not yet in their inventory) and prep services.
/// </summary>
public async Task<IReadOnlyList<QuoteItem>> CreateQuoteItemsAsync(
IEnumerable<CreateQuoteItemDto> itemDtos,
int quoteId,
int companyId,
decimal? ovenRateOverride,
DateTime createdAtUtc)
{
ArgumentNullException.ThrowIfNull(itemDtos);
var items = new List<QuoteItem>();
foreach (var itemDto in itemDtos)
{
var item = BuildQuoteItem(itemDto, quoteId, companyId, createdAtUtc);
await ApplyPricingAsync(item, itemDto, companyId, ovenRateOverride);
await UpdateAiPredictionOverrideAsync(itemDto, item.UnitPrice);
item.Coats = await BuildQuoteItemCoatsAsync(itemDto, companyId, createdAtUtc);
item.PrepServices = BuildQuoteItemPrepServices(itemDto, companyId, createdAtUtc);
items.Add(item);
}
return items;
}
/// <summary>
/// Routes a single item to the correct pricing path and stamps the result onto the entity.
/// Priority order matches the routing table in <see cref="PricingCalculationService.CalculateQuoteItemPriceAsync"/>:
/// AI items → Sales items → Catalog (no coats) → full calculation engine.
/// Keeping pricing logic in PricingCalculationService means this method only decides WHICH
/// path to take, never HOW to compute the price.
/// </summary>
private async Task ApplyPricingAsync(QuoteItem item, CreateQuoteItemDto itemDto, int companyId, decimal? ovenRateOverride)
{
if (itemDto.IsAiItem && itemDto.ManualUnitPrice.HasValue && itemDto.ManualUnitPrice.Value > 0)
{
item.UnitPrice = itemDto.ManualUnitPrice.Value;
item.TotalPrice = itemDto.ManualUnitPrice.Value * itemDto.Quantity;
_logger.LogInformation("AI item price: UnitPrice={Unit}, TotalPrice={Total}", item.UnitPrice, item.TotalPrice);
return;
}
if (itemDto.IsSalesItem && itemDto.ManualUnitPrice.HasValue)
{
item.UnitPrice = itemDto.ManualUnitPrice.Value;
item.TotalPrice = itemDto.ManualUnitPrice.Value * itemDto.Quantity;
_logger.LogInformation("Sales item price: UnitPrice={Unit}, TotalPrice={Total}", item.UnitPrice, item.TotalPrice);
return;
}
if (itemDto.CatalogItemId.HasValue)
{
if (itemDto.Coats != null && itemDto.Coats.Any())
{
_logger.LogInformation("Calculating catalog item with {CoatCount} coats", itemDto.Coats.Count);
var itemPricing = await _pricingService.CalculateQuoteItemPriceAsync(itemDto, companyId, ovenRateOverride);
ApplyCalculatedPricing(item, itemPricing);
return;
}
var catalogItem = await _unitOfWork.CatalogItems.GetByIdAsync(itemDto.CatalogItemId.Value);
if (catalogItem != null)
{
var unitPrice = itemDto.PowderCostOverride is > 0
? itemDto.PowderCostOverride.Value
: catalogItem.DefaultPrice;
item.UnitPrice = unitPrice;
item.TotalPrice = unitPrice * itemDto.Quantity;
_logger.LogInformation("Catalog item no coats: UnitPrice={Unit}, TotalPrice={Total}", item.UnitPrice, item.TotalPrice);
}
return;
}
_logger.LogInformation("Calculating custom item with {CoatCount} coats", itemDto.Coats?.Count ?? 0);
var pricing = await _pricingService.CalculateQuoteItemPriceAsync(itemDto, companyId, ovenRateOverride);
ApplyCalculatedPricing(item, pricing);
}
/// <summary>
/// Builds <see cref="QuoteItemCoat"/> entities for a single item, including per-coat pricing.
/// If a coat has <c>AddAsIncoming = true</c> and references a catalog item but not an inventory
/// item, an incoming <see cref="InventoryItem"/> is auto-created so the shop can track the powder
/// order and receive it later — see <see cref="CreateIncomingInventoryItemAsync"/> for details.
/// </summary>
private async Task<List<QuoteItemCoat>> BuildQuoteItemCoatsAsync(CreateQuoteItemDto itemDto, int companyId, DateTime createdAtUtc)
{
if (itemDto.Coats == null || itemDto.Coats.Count == 0)
return [];
var coats = new List<QuoteItemCoat>();
for (var coatIndex = 0; coatIndex < itemDto.Coats.Count; coatIndex++)
{
var coatDto = itemDto.Coats[coatIndex];
if (coatDto.AddAsIncoming && coatDto.CatalogItemId.HasValue && !coatDto.InventoryItemId.HasValue)
coatDto.InventoryItemId = await CreateIncomingInventoryItemAsync(coatDto, companyId);
var coat = BuildQuoteItemCoat(coatDto, companyId, createdAtUtc);
var coatPricing = await _pricingService.CalculateCoatPriceAsync(
coatDto,
itemDto.SurfaceAreaSqFt,
itemDto.Quantity,
coatIndex,
itemDto.EstimatedMinutes,
companyId);
coat.CoatMaterialCost = coatPricing.CoatMaterialCost;
coat.CoatLaborCost = coatPricing.CoatLaborCost;
coat.CoatTotalCost = coatPricing.CoatTotalCost;
coats.Add(coat);
}
return coats;
}
/// <summary>Constructs <see cref="QuoteItemPrepService"/> entities from the item DTO's prep service list.</summary>
private static List<QuoteItemPrepService> BuildQuoteItemPrepServices(CreateQuoteItemDto itemDto, int companyId, DateTime createdAtUtc)
{
if (itemDto.PrepServices == null || itemDto.PrepServices.Count == 0)
return [];
return itemDto.PrepServices
.Select(ps => new QuoteItemPrepService
{
PrepServiceId = ps.PrepServiceId,
EstimatedMinutes = ps.EstimatedMinutes,
BlastSetupId = ps.BlastSetupId,
CompanyId = companyId,
CreatedAt = createdAtUtc
})
.ToList();
}
/// <summary>
/// Constructs a bare <see cref="QuoteItem"/> entity from the DTO — no pricing or coats yet.
/// Pricing is applied separately by <see cref="ApplyPricingAsync"/> to keep the construction
/// and calculation steps distinct and individually testable.
/// </summary>
private static QuoteItem BuildQuoteItem(CreateQuoteItemDto itemDto, int quoteId, int companyId, DateTime createdAtUtc)
{
return new QuoteItem
{
QuoteId = quoteId,
Description = itemDto.Description,
Quantity = itemDto.Quantity,
SurfaceAreaSqFt = itemDto.SurfaceAreaSqFt,
CatalogItemId = itemDto.CatalogItemId,
IsGenericItem = itemDto.IsGenericItem,
ManualUnitPrice = itemDto.ManualUnitPrice,
PowderCostOverride = itemDto.PowderCostOverride,
IsLaborItem = itemDto.IsLaborItem,
IsSalesItem = itemDto.IsSalesItem,
Sku = itemDto.Sku,
RequiresSandblasting = itemDto.RequiresSandblasting,
RequiresMasking = itemDto.RequiresMasking,
EstimatedMinutes = itemDto.EstimatedMinutes,
IncludePrepCost = itemDto.IncludePrepCost,
Notes = itemDto.Notes,
Complexity = itemDto.Complexity,
IsAiItem = itemDto.IsAiItem,
AiTags = itemDto.AiTags,
AiPredictionId = itemDto.AiPredictionId,
CompanyId = companyId,
CreatedAt = createdAtUtc
};
}
/// <summary>Constructs a <see cref="QuoteItemCoat"/> entity from the coat DTO. Per-coat pricing is applied by the caller.</summary>
private static QuoteItemCoat BuildQuoteItemCoat(CreateQuoteItemCoatDto coatDto, int companyId, DateTime createdAtUtc)
{
return new QuoteItemCoat
{
CoatName = coatDto.CoatName,
Sequence = coatDto.Sequence,
InventoryItemId = coatDto.InventoryItemId,
ColorName = coatDto.ColorName,
VendorId = coatDto.VendorId,
ColorCode = coatDto.ColorCode,
Finish = coatDto.Finish,
CoverageSqFtPerLb = coatDto.CoverageSqFtPerLb,
TransferEfficiency = coatDto.TransferEfficiency,
PowderCostPerLb = coatDto.PowderCostPerLb,
PowderToOrder = coatDto.PowderToOrder,
Notes = coatDto.Notes,
CompanyId = companyId,
CreatedAt = createdAtUtc
};
}
/// <summary>
/// Stamps the pricing result onto the quote item entity.
/// Broken out as a separate method because it's called from multiple branches of ApplyPricingAsync.
/// </summary>
private static void ApplyCalculatedPricing(QuoteItem item, QuoteItemPricingResult pricing)
{
item.UnitPrice = pricing.UnitPrice;
item.TotalPrice = pricing.TotalPrice;
item.ItemMaterialCost = pricing.MaterialCost;
item.ItemLaborCost = pricing.LaborCost;
item.ItemEquipmentCost = pricing.EquipmentCost;
}
/// <summary>
/// Checks whether the user changed the AI's surface area or price estimates before saving,
/// and sets <c>UserOverrodeEstimate = true</c> on the prediction record if they did.
/// This flag feeds the AI analytics reports — over time it reveals how accurate the AI is
/// and whether certain item types consistently need manual correction.
/// A tolerance of $0.01 / 0.01 sqft is used to ignore floating-point rounding noise.
/// </summary>
private async Task UpdateAiPredictionOverrideAsync(CreateQuoteItemDto itemDto, decimal finalUnitPrice)
{
if (!itemDto.AiPredictionId.HasValue) return;
var prediction = await _unitOfWork.AiItemPredictions.GetByIdAsync(itemDto.AiPredictionId.Value);
if (prediction == null) return;
var sqftDiff = Math.Abs(prediction.PredictedSurfaceAreaSqFt - itemDto.SurfaceAreaSqFt);
var priceDiff = Math.Abs(prediction.PredictedUnitPrice - finalUnitPrice);
prediction.UserOverrodeEstimate = sqftDiff > 0.01m || priceDiff > 0.01m;
prediction.UpdatedAt = DateTime.UtcNow;
}
/// <summary>
/// Auto-creates an "incoming" <see cref="InventoryItem"/> when a user selects a powder from the
/// platform catalog that doesn't yet exist in their company's inventory.
///
/// WHY this exists: shops often quote jobs using powders they haven't ordered yet. Rather than
/// forcing the user to manually add the powder to inventory before quoting, we create an
/// IsIncoming=true record on their behalf. The shop can then receive the actual order against
/// this record later (updating quantity + receive date) without losing the link to the original quote.
///
/// The AI augmentation step (LookupByUrlAsync) fills in technical specs (cure temp/time, coverage,
/// color families, etc.) that may be missing from the scraped catalog JSON. It is best-effort —
/// if it fails, the item is still created with whatever data the catalog has.
///
/// After creation, <c>coatDto.PowderCostPerLb</c> is cleared so the pricing engine treats this
/// as an inventory-linked coat (not a custom powder), ensuring future repricings use the
/// inventory unit cost rather than the now-stale manual price from the quote form.
/// </summary>
private async Task<int?> CreateIncomingInventoryItemAsync(CreateQuoteItemCoatDto coatDto, int companyId)
{
try
{
var catalogItem = await _unitOfWork.PowderCatalog.GetByIdAsync(coatDto.CatalogItemId!.Value);
if (catalogItem == null) return null;
var categories = await _unitOfWork.InventoryCategoryLookups.GetAllAsync();
var coatingCategory = categories
.Where(c => c.IsActive && c.IsCoating)
.OrderBy(c => c.DisplayOrder)
.FirstOrDefault();
var vendors = await _unitOfWork.Vendors.GetAllAsync();
var vendorNameLower = catalogItem.VendorName.ToLower();
var matchedVendor = vendors.FirstOrDefault(v =>
v.CompanyName.ToLower().Contains(vendorNameLower) ||
vendorNameLower.Contains(v.CompanyName.ToLower()));
var code = coatingCategory != null
? (coatingCategory.CategoryCode.Length >= 4
? coatingCategory.CategoryCode[..4].ToUpperInvariant()
: coatingCategory.CategoryCode.ToUpperInvariant().PadRight(4, 'X'))
: "POWD";
var prefix = $"{code}-{DateTime.Now:yyMM}-";
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();
var sku = $"{prefix}{(maxSeq + 1):D4}";
var name = System.Globalization.CultureInfo.CurrentCulture.TextInfo
.ToTitleCase(catalogItem.ColorName.Trim().ToLower());
var description = catalogItem.Description;
var finish = catalogItem.Finish;
var colorFamilies = catalogItem.ColorFamilies;
var cureTemp = catalogItem.CureTemperatureF;
var cureTime = catalogItem.CureTimeMinutes;
var coverage = catalogItem.CoverageSqFtPerLb;
var transferEff = catalogItem.TransferEfficiency;
var specificGravity = catalogItem.SpecificGravity;
var imageUrl = catalogItem.ImageUrl;
var sdsUrl = catalogItem.SdsUrl;
var tdsUrl = catalogItem.TdsUrl;
var needsAugment = !string.IsNullOrWhiteSpace(catalogItem.ProductUrl) &&
(string.IsNullOrWhiteSpace(description) ||
string.IsNullOrWhiteSpace(colorFamilies) ||
cureTemp == null || cureTime == null);
if (needsAugment)
{
try
{
var augmented = await _aiLookupService.LookupByUrlAsync(catalogItem.ProductUrl!, catalogItem.ColorName, catalogItem.TdsUrl);
if (augmented.Success)
{
description = string.IsNullOrWhiteSpace(description) ? augmented.Description : description;
finish = string.IsNullOrWhiteSpace(finish) ? augmented.Finish : finish;
colorFamilies = string.IsNullOrWhiteSpace(colorFamilies) ? augmented.ColorFamilies : colorFamilies;
cureTemp ??= augmented.CureTemperatureF;
cureTime ??= augmented.CureTimeMinutes;
coverage ??= augmented.CoverageSqFtPerLb;
transferEff ??= augmented.TransferEfficiency;
specificGravity ??= augmented.SpecificGravity;
imageUrl = string.IsNullOrWhiteSpace(imageUrl) ? augmented.ImageUrl : imageUrl;
sdsUrl = string.IsNullOrWhiteSpace(sdsUrl) ? augmented.SdsUrl : sdsUrl;
tdsUrl = string.IsNullOrWhiteSpace(tdsUrl) ? augmented.TdsUrl : tdsUrl;
_logger.LogInformation("AI-augmented incoming inventory item for catalog {CatalogId}", catalogItem.Id);
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "AI augment failed for catalog {CatalogId}, continuing with catalog data", catalogItem.Id);
}
}
var item = new InventoryItem
{
SKU = sku,
Name = name,
Description = description,
ColorName = catalogItem.ColorName,
Manufacturer = catalogItem.VendorName,
ManufacturerPartNumber = catalogItem.Sku,
Finish = finish,
ColorFamilies = colorFamilies,
RequiresClearCoat = catalogItem.RequiresClearCoat ?? false,
CoverageSqFtPerLb = coverage ?? 30m,
TransferEfficiency = transferEff ?? 65m,
CureTemperatureF = cureTemp,
CureTimeMinutes = cureTime,
SpecificGravity = specificGravity,
SpecPageUrl = catalogItem.ProductUrl,
ImageUrl = imageUrl,
SdsUrl = sdsUrl,
TdsUrl = tdsUrl,
UnitCost = catalogItem.UnitPrice,
AverageCost = catalogItem.UnitPrice,
LastPurchasePrice = catalogItem.UnitPrice,
QuantityOnHand = 0,
UnitOfMeasure = "lbs",
PrimaryVendorId = matchedVendor?.Id,
InventoryCategoryId = coatingCategory?.Id,
Category = coatingCategory?.DisplayName ?? "Powder Coating",
IsActive = true,
IsIncoming = true,
CompanyId = companyId,
CreatedAt = DateTime.UtcNow,
};
await _unitOfWork.InventoryItems.AddAsync(item);
await _unitOfWork.SaveChangesAsync();
coatDto.PowderCostPerLb = null;
_logger.LogInformation("Created incoming inventory item {Id} ({Name}) from catalog {CatalogId} via quote coat",
item.Id, item.Name, coatDto.CatalogItemId);
return item.Id;
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to create incoming inventory item from catalog {CatalogId}, continuing without inventory link",
coatDto.CatalogItemId);
return null;
}
}
}
@@ -122,6 +122,10 @@ public class BillPayment : BaseEntity
public string? CheckNumber { get; set; }
public string? Memo { get; set; }
/// <summary>True once this payment has been matched against a bank statement during reconciliation.</summary>
public bool IsCleared { get; set; } = false;
public DateTime? ClearedDate { get; set; }
// Navigation
public virtual Bill Bill { get; set; } = null!;
public virtual Vendor Vendor { get; set; } = null!;
@@ -150,9 +154,305 @@ public class Expense : BaseEntity
public string? Memo { get; set; }
public string? ReceiptFilePath { get; set; }
/// <summary>True once this expense has been matched against a bank statement during reconciliation.</summary>
public bool IsCleared { get; set; } = false;
public DateTime? ClearedDate { get; set; }
// Navigation
public virtual Vendor? Vendor { get; set; }
public virtual Account ExpenseAccount { get; set; } = null!;
public virtual Account PaymentAccount { get; set; } = null!;
public virtual Job? Job { get; set; }
}
/// <summary>
/// Manual double-entry journal entry. Lines must balance (sum of debits == sum of credits)
/// before posting. Once posted the entry is immutable — use Reverse to correct it.
/// Entry numbering follows the pattern JE-YYMM-#### scoped per company.
/// </summary>
public class JournalEntry : BaseEntity
{
public string EntryNumber { get; set; } = string.Empty;
public DateTime EntryDate { get; set; } = DateTime.UtcNow;
public string? Reference { get; set; }
public string? Description { get; set; }
public JournalEntryStatus Status { get; set; } = JournalEntryStatus.Draft;
/// <summary>True if this entry was machine-generated as a reversal of another entry.</summary>
public bool IsReversal { get; set; } = false;
/// <summary>FK to the original entry being reversed. Null for normal entries.</summary>
public int? ReversalOfId { get; set; }
public DateTime? PostedAt { get; set; }
public string? PostedBy { get; set; }
// Navigation
public virtual ICollection<JournalEntryLine> Lines { get; set; } = new List<JournalEntryLine>();
public virtual JournalEntry? ReversalOf { get; set; }
}
/// <summary>
/// One debit or credit line within a <see cref="JournalEntry"/>. Either DebitAmount or CreditAmount
/// should be non-zero per line (not both). LineOrder controls display sequence.
/// </summary>
public class JournalEntryLine : BaseEntity
{
public int JournalEntryId { get; set; }
public int AccountId { get; set; }
public decimal DebitAmount { get; set; }
public decimal CreditAmount { get; set; }
public string? Description { get; set; }
public int LineOrder { get; set; }
// Navigation
public virtual JournalEntry JournalEntry { get; set; } = null!;
public virtual Account Account { get; set; } = null!;
}
/// <summary>
/// A bank reconciliation session for a single bank/cash account against a statement.
/// Cleared balance = BeginningBalance + cleared deposits - cleared payments.
/// The reconciliation is complete when Difference (EndingBalance - ClearedBalance) == 0.
/// </summary>
public class BankReconciliation : BaseEntity
{
/// <summary>Must be a bank/cash subtype account.</summary>
public int AccountId { get; set; }
public DateTime StatementDate { get; set; }
public decimal BeginningBalance { get; set; }
public decimal EndingBalance { get; set; }
public BankReconciliationStatus Status { get; set; } = BankReconciliationStatus.InProgress;
public DateTime? CompletedAt { get; set; }
public string? CompletedBy { get; set; }
public string? Notes { get; set; }
// Navigation
public virtual Account Account { get; set; } = null!;
}
/// <summary>
/// A credit note received from a vendor (returned goods, pricing dispute, short-ship).
/// Reduces Accounts Payable and reverses the original expense/COGS when posted.
/// Numbering: VC-YYMM-####
/// </summary>
public class VendorCredit : BaseEntity
{
public string CreditNumber { get; set; } = string.Empty;
public int VendorId { get; set; }
/// <summary>AP account this credit reduces (default: Accounts Payable 2000).</summary>
public int APAccountId { get; set; }
public DateTime CreditDate { get; set; } = DateTime.UtcNow;
public VendorCreditStatus Status { get; set; } = VendorCreditStatus.Open;
public decimal Total { get; set; }
public decimal RemainingAmount { get; set; }
public string? Memo { get; set; }
/// <summary>Set by Post() when GL entries are made (DR AP / CR expense lines). Null = unposted.</summary>
public DateTime? PostedDate { get; set; }
// Navigation
public virtual Vendor Vendor { get; set; } = null!;
public virtual Account APAccount { get; set; } = null!;
public virtual ICollection<VendorCreditLineItem> LineItems { get; set; } = new List<VendorCreditLineItem>();
public virtual ICollection<VendorCreditApplication> Applications { get; set; } = new List<VendorCreditApplication>();
}
/// <summary>
/// A single line on a vendor credit, each reversing a specific expense/COGS account.
/// </summary>
public class VendorCreditLineItem : BaseEntity
{
public int VendorCreditId { get; set; }
/// <summary>Expense/COGS account being reversed by this line.</summary>
public int? AccountId { get; set; }
public string Description { get; set; } = string.Empty;
public decimal Amount { get; set; }
// Navigation
public virtual VendorCredit VendorCredit { get; set; } = null!;
public virtual Account? Account { get; set; }
}
/// <summary>
/// Records the application of a vendor credit against a specific vendor bill.
/// No additional GL posting is needed — AP was already adjusted when the credit was posted.
/// </summary>
public class VendorCreditApplication : BaseEntity
{
public int VendorCreditId { get; set; }
public int BillId { get; set; }
public decimal Amount { get; set; }
public DateTime AppliedDate { get; set; } = DateTime.UtcNow;
// Navigation
public virtual VendorCredit VendorCredit { get; set; } = null!;
public virtual Bill Bill { get; set; } = null!;
}
/// <summary>
/// A saved recipe for a document that should be automatically created on a recurring schedule.
/// The <see cref="TemplateData"/> column stores a JSON blob whose schema depends on
/// <see cref="TemplateType"/>: see <c>RecurringTransactionService</c> for the exact shape.
/// <para>
/// Bills are created as Draft so the user can review before posting.
/// Expenses are created immediately (already-paid transactions).
/// </para>
/// Numbering: REC-YYMM-####
/// </summary>
public class RecurringTemplate : BaseEntity
{
public string Name { get; set; } = string.Empty;
public RecurringTemplateType TemplateType { get; set; }
public RecurringFrequency Frequency { get; set; }
/// <summary>Every N periods. E.g. Frequency=Monthly, IntervalCount=3 → quarterly.</summary>
public int IntervalCount { get; set; } = 1;
/// <summary>UTC date when the template will next fire. Set to the desired first occurrence date on creation.</summary>
public DateTime NextFireDate { get; set; }
/// <summary>Optional UTC date after which no further occurrences are generated.</summary>
public DateTime? EndDate { get; set; }
/// <summary>Optional hard cap on total occurrences. Null = unlimited.</summary>
public int? MaxOccurrences { get; set; }
/// <summary>How many documents have been generated so far.</summary>
public int OccurrenceCount { get; set; }
public bool IsActive { get; set; } = true;
/// <summary>JSON payload whose schema matches the TemplateType. See RecurringTransactionService.</summary>
public string TemplateData { get; set; } = "{}";
/// <summary>Last error from the background service, cleared on next successful fire.</summary>
public string? LastError { get; set; }
}
/// <summary>
/// A named tax rate (e.g., "CA Sales Tax 8.25%") used to pre-fill the TaxPercent field on
/// invoices when a taxable customer is selected. Companies can define multiple rates for
/// different jurisdictions and mark one as default.
/// </summary>
public class TaxRate : BaseEntity
{
public string Name { get; set; } = string.Empty;
/// <summary>Rate as a percentage, e.g., 8.25 means 8.25%.</summary>
public decimal Rate { get; set; }
public string? State { get; set; }
public string? Description { get; set; }
/// <summary>When true, this rate is auto-applied to new invoices for taxable customers.</summary>
public bool IsDefault { get; set; }
public bool IsActive { get; set; } = true;
}
/// <summary>
/// A depreciable fixed asset (oven, blast cabinet, spray booth, vehicle, etc.).
/// Stores straight-line depreciation parameters and links to the three GL accounts needed
/// to auto-post monthly depreciation journal entries.
/// </summary>
public class FixedAsset : BaseEntity
{
public string Name { get; set; } = string.Empty;
public string? Description { get; set; }
public DateTime PurchaseDate { get; set; }
public decimal PurchaseCost { get; set; }
/// <summary>Residual value at end of useful life (often $0 for shop equipment).</summary>
public decimal SalvageValue { get; set; } = 0;
/// <summary>Total depreciation period in months (e.g., 60 = 5 years).</summary>
public int UsefulLifeMonths { get; set; }
/// <summary>Running total of depreciation posted so far.</summary>
public decimal AccumulatedDepreciation { get; set; } = 0;
public bool IsDisposed { get; set; } = false;
public DateTime? DisposalDate { get; set; }
// Computed — not persisted
/// <summary>Current net book value: PurchaseCost minus AccumulatedDepreciation.</summary>
public decimal BookValue => PurchaseCost - AccumulatedDepreciation;
/// <summary>Straight-line monthly depreciation amount.</summary>
public decimal MonthlyDepreciation => UsefulLifeMonths > 0
? Math.Round((PurchaseCost - SalvageValue) / UsefulLifeMonths, 2) : 0;
// GL account links — all optional; assets without accounts can be tracked but not auto-posted
/// <summary>Balance Sheet FixedAsset account (debited when asset is purchased).</summary>
public int? AssetAccountId { get; set; }
/// <summary>P&L Depreciation Expense account (debited each period).</summary>
public int? DepreciationExpenseAccountId { get; set; }
/// <summary>Balance Sheet Accumulated Depreciation account (credited each period).</summary>
public int? AccumDepreciationAccountId { get; set; }
// Navigation
public virtual Account? AssetAccount { get; set; }
public virtual Account? DepreciationExpenseAccount { get; set; }
public virtual Account? AccumDepreciationAccount { get; set; }
public virtual ICollection<FixedAssetDepreciationEntry> DepreciationEntries { get; set; } = new List<FixedAssetDepreciationEntry>();
}
/// <summary>
/// Records each periodic depreciation posting for a fixed asset. One record per asset per
/// month/year combination; linked to the JournalEntry that was created so the posting
/// can be traced back through the GL.
/// </summary>
public class FixedAssetDepreciationEntry : BaseEntity
{
public int FixedAssetId { get; set; }
public int PeriodYear { get; set; }
public int PeriodMonth { get; set; }
public decimal Amount { get; set; }
/// <summary>The JE that was posted for this depreciation period (null if manually recorded).</summary>
public int? JournalEntryId { get; set; }
// Navigation
public virtual FixedAsset FixedAsset { get; set; } = null!;
public virtual JournalEntry? JournalEntry { get; set; }
}
/// <summary>
/// A named annual budget. Contains one BudgetLine per account per month. Supports
/// multiple budgets per fiscal year (e.g. "Conservative" vs "Optimistic") but only
/// one is marked IsDefault for the Budget vs. Actual report.
/// </summary>
public class Budget : BaseEntity
{
public string Name { get; set; } = string.Empty;
public int FiscalYear { get; set; }
public string? Notes { get; set; }
public bool IsDefault { get; set; } = false;
public virtual ICollection<BudgetLine> Lines { get; set; } = new List<BudgetLine>();
}
/// <summary>
/// Monthly budget amount for one account within a Budget. JanDec stored as separate
/// columns so the grid editor can write them in a single POST without a line-item loop.
/// Annual is a computed property summing all twelve months.
/// </summary>
public class BudgetLine : BaseEntity
{
public int BudgetId { get; set; }
public int AccountId { get; set; }
public decimal Jan { get; set; }
public decimal Feb { get; set; }
public decimal Mar { get; set; }
public decimal Apr { get; set; }
public decimal May { get; set; }
public decimal Jun { get; set; }
public decimal Jul { get; set; }
public decimal Aug { get; set; }
public decimal Sep { get; set; }
public decimal Oct { get; set; }
public decimal Nov { get; set; }
public decimal Dec { get; set; }
public decimal Annual => Jan + Feb + Mar + Apr + May + Jun + Jul + Aug + Sep + Oct + Nov + Dec;
public virtual Budget Budget { get; set; } = null!;
public virtual Account Account { get; set; } = null!;
}
/// <summary>
/// Records a completed year-end close. The close posts a JE that zeroes all
/// Revenue and Expense account balances into Retained Earnings, and marks
/// the year as closed so it cannot be closed again.
/// </summary>
public class YearEndClose : BaseEntity
{
public int ClosedYear { get; set; }
public DateTime ClosedAt { get; set; } = DateTime.UtcNow;
public string? ClosedBy { get; set; }
public int JournalEntryId { get; set; }
public virtual JournalEntry JournalEntry { get; set; } = null!;
}
@@ -50,13 +50,22 @@ public class ApplicationUser : IdentityUser
public bool CanManageMaintenance { get; set; } = false;
public bool CanManageInvoices { get; set; } = false;
public bool CanViewReports { get; set; } = false;
public bool CanManageBills { get; set; } = false;
public bool CanManageAccounting { get; set; } = false;
// Profile Photo (filesystem storage)
public string? ProfilePictureFilePath { get; set; } // Relative path from ContentRoot/media/ (e.g., "123/profile-photos/user-abc.jpg")
public string? SidebarColor { get; set; } = "ocean";
public string? Notes { get; set; }
/// <summary>
/// Per-worker labor cost per hour used for job costing profit/margin calculations.
/// Overrides the company-level LaborCostPerHour when set.
/// Leave null to use the company default.
/// </summary>
public decimal? LaborCostPerHour { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime? UpdatedAt { get; set; }
public DateTime? LastLoginDate { get; set; }
@@ -95,6 +95,12 @@ public class Appointment : BaseEntity
/// </summary>
public int ReminderMinutesBefore { get; set; } = 30;
/// <summary>
/// UTC timestamp when the reminder was dispatched. Null means it hasn't fired yet.
/// The background service uses this as a deduplication guard to prevent double-sending.
/// </summary>
public DateTime? ReminderSentAt { get; set; }
// Navigation Properties
public virtual Customer? Customer { get; set; }
public virtual Job? Job { get; set; }
+24 -2
View File
@@ -105,11 +105,34 @@ public class Company : BaseEntity
public bool MarketingEmailOptOut { get; set; } = false;
public string MarketingUnsubscribeToken { get; set; } = Guid.NewGuid().ToString("N");
/// <summary>
/// Determines whether financial reports (P&amp;L, Balance Sheet, Cash Flow) use
/// cash-basis or accrual-basis presentation. Switchable at any time — no GL
/// re-posting occurs. Default is Accrual (standard for most businesses).
/// </summary>
public AccountingMethod AccountingMethod { get; set; } = AccountingMethod.Accrual;
/// <summary>
/// When set, prevents creating or editing accounting entries (JEs, bills, expenses) with dates
/// on or before this date. Protects closed periods from accidental backdating. Null = no lock.
/// </summary>
public DateTime? BookLockedThrough { get; set; }
// Settings
public string? TimeZone { get; set; } = "America/New_York";
public byte[]? LogoData { get; set; } // Legacy - kept for backward compatibility
public string? LogoContentType { get; set; } // Legacy - kept for backward compatibility
public string? LogoFilePath { get; set; } // Filesystem path: /media/{CompanyId}/company-logo.{ext}
// Kiosk
/// <summary>
/// Random token written to a long-lived HttpOnly cookie on the front-desk tablet when the
/// owner activates the kiosk. Kiosk routes validate this token against the cookie so the
/// tablet can serve the intake form without requiring a logged-in user.
/// Null = kiosk not activated. Regenerate to revoke the current device.
/// </summary>
public string? KioskActivationToken { get; set; }
// Navigation Properties
public virtual ICollection<ApplicationUser> Users { get; set; } = new List<ApplicationUser>();
public virtual ICollection<Customer> Customers { get; set; } = new List<Customer>();
@@ -118,8 +141,7 @@ public class Company : BaseEntity
public virtual ICollection<Quote> Quotes { get; set; } = new List<Quote>();
public virtual ICollection<InventoryItem> InventoryItems { get; set; } = new List<InventoryItem>();
public virtual ICollection<Vendor> Vendors { get; set; } = new List<Vendor>();
public virtual ICollection<ShopWorker> ShopWorkers { get; set; } = new List<ShopWorker>();
public virtual ICollection<PricingTier> PricingTiers { get; set; } = new List<PricingTier>();
public virtual ICollection<PricingTier> PricingTiers { get; set; } = new List<PricingTier>();
public virtual CompanyOperatingCosts? OperatingCosts { get; set; }
public virtual CompanyPreferences? Preferences { get; set; }
}
@@ -13,6 +13,14 @@ namespace PowderCoating.Core.Entities
[Range(0, 10000)]
public decimal StandardLaborRate { get; set; }
/// <summary>
/// Actual labor cost per hour (wages + burden) used exclusively for internal job costing and profit/margin display.
/// This is NOT the billing rate — it should reflect what you actually pay workers.
/// When null, the costing engine defaults to 20% of StandardLaborRate.
/// </summary>
[Range(0, 10000)]
public decimal? LaborCostPerHour { get; set; }
// Additional Coat Labor Percentage (percentage of base labor for each additional coat beyond the first)
[Range(0, 100)]
public decimal AdditionalCoatLaborPercent { get; set; } = 30m;
@@ -86,6 +86,14 @@ public class CompanyPreferences : BaseEntity
/// <summary>JSON blob persisting QB Migration Wizard step state across sessions.</summary>
public string? QbMigrationStateJson { get; set; }
// Kiosk settings
/// <summary>
/// Controls what the kiosk creates on submission: "Quote" (default) or "Job".
/// Quote aligns with the default Terms text ("subject to a formal quote").
/// Job is for shops that price on the spot and want the work order ready immediately.
/// </summary>
public string KioskIntakeOutput { get; set; } = "Quote";
// Guided activation / first-workflow onboarding
/// <summary>Selected first-workflow path: quote_first or job_first. Null until chosen.</summary>
public string? OnboardingPath { get; set; }
@@ -6,6 +6,7 @@ public class Customer : BaseEntity
public string? ContactFirstName { get; set; }
public string? ContactLastName { get; set; }
public string? Email { get; set; }
public string? BillingEmail { get; set; } // Accounting/invoicing email for commercial customers
public string? Phone { get; set; }
public string? MobilePhone { get; set; }
public string? Address { get; set; }
@@ -15,6 +15,10 @@ public class Deposit : BaseEntity
public string? Notes { get; set; }
public string? RecordedById { get; set; }
/// <summary>Bank/checking account this deposit was deposited into. Set at recording time
/// so the Trial Balance can immediately debit the correct bank account.</summary>
public int? DepositAccountId { get; set; }
// Applied to invoice when invoice is created
public int? AppliedToInvoiceId { get; set; }
public DateTime? AppliedDate { get; set; }
@@ -32,6 +32,9 @@ public class GiftCertificate : BaseEntity
/// <summary>Set when this GC was sold via an invoice line item.</summary>
public int? SourceInvoiceItemId { get; set; }
/// <summary>Groups all certificates created in a single bulk run. Null for individually issued certs.</summary>
public Guid? BatchId { get; set; }
// Navigation
public virtual Customer? RecipientCustomer { get; set; }
public virtual Customer? PurchasingCustomer { get; set; }
@@ -58,6 +58,13 @@ public class InventoryItem : BaseEntity
public bool IsActive { get; set; } = true;
public DateTime? DiscontinuedDate { get; set; }
/// <summary>
/// True when this item was added to inventory as an ordered-but-not-yet-received powder.
/// Staff can quote and print QR codes while the powder is in transit.
/// Cleared automatically when a Purchase receipt is posted or staff manually unchecks it.
/// </summary>
public bool IsIncoming { get; set; } = false;
// ── Financial Account Mapping ──────────────────────────────────────────
/// <summary>
@@ -28,6 +28,13 @@ public class Invoice : BaseEntity
public decimal GiftCertificateRedeemed { get; set; } // Sum of gift certificate redemptions
public decimal BalanceDue => Total - AmountPaid - CreditApplied - GiftCertificateRedeemed;
/// <summary>
/// Permanent public token for the customer-facing invoice view page (/invoice/{token}).
/// Generated when the invoice is first sent (regardless of Stripe status) and never expires.
/// Distinct from PaymentLinkToken which is Stripe-gated and expires in 5 days.
/// </summary>
public string? PublicViewToken { get; set; }
// Online payments (Stripe Connect)
public OnlinePaymentStatus OnlinePaymentStatus { get; set; } = OnlinePaymentStatus.NotApplicable;
public string? PaymentLinkToken { get; set; } // Signed token for /pay/{token}
@@ -42,6 +49,19 @@ public class Invoice : BaseEntity
public string? Terms { get; set; }
public string? CustomerPO { get; set; }
/// <summary>
/// Early payment discount percentage (e.g., 2 means 2% discount).
/// Parsed from the customer's payment terms when the invoice is created (e.g., "2/10 Net 30").
/// Informational only — does not automatically reduce the amount due.
/// </summary>
public decimal EarlyPaymentDiscountPercent { get; set; }
/// <summary>
/// Number of days after invoice date within which the early payment discount applies.
/// Parsed from the customer's payment terms (e.g., "2/10 Net 30" → 10 days).
/// </summary>
public int EarlyPaymentDiscountDays { get; set; }
/// <summary>
/// Original invoice number from an external system (e.g. QuickBooks invoice # "3048").
/// Stored for searchability and traceability after import. Searchable from the invoice list.
+11
View File
@@ -25,9 +25,16 @@ public class Job : BaseEntity
// Selected oven (carried over from quote; null = company default rate)
public int? OvenCostId { get; set; }
// Oven scheduling (carried over from quote)
public int OvenBatches { get; set; } = 1;
public int? OvenCycleMinutes { get; set; }
// Pricing
public decimal QuotedPrice { get; set; }
public decimal FinalPrice { get; set; }
public decimal OvenBatchCost { get; set; }
public decimal ShopSuppliesAmount { get; set; }
public decimal ShopSuppliesPercent { get; set; }
// Discount & rush (mirrors quote fields; preserved through quote→job conversion and job edits)
public bool IsRushJob { get; set; }
@@ -59,6 +66,10 @@ public class Job : BaseEntity
// Used to detect when the quote was subsequently edited so the job details page can warn the user.
public DateTime? QuoteSnapshotUpdatedAt { get; set; }
// Pricing snapshot — serialized QuotePricingBreakdownDto stored at save time so Details displays
// the breakdown that was actually calculated, not a re-run against current operating costs.
public string? PricingBreakdownJson { get; set; }
// Rework tracking
public bool IsReworkJob { get; set; }
public int? OriginalJobId { get; set; } // Set when this job was created as a rework
@@ -41,6 +41,10 @@ public class JobItem : BaseEntity
// Values: "Simple" | "Moderate" | "Complex" | "Extreme"
public string? Complexity { get; set; }
// True when this item originated from an AI Photo Quote — ManualUnitPrice is used as-is
// and oven cost is not double-charged (it was excluded from the AI estimate at quote level).
public bool IsAiItem { get; set; }
// AI-generated standardized tags (comma-separated, e.g. "automotive,tubular")
public string? AiTags { get; set; }
@@ -42,6 +42,13 @@ public class JobItemCoat : BaseEntity
public string? PowderReceivedByUserId { get; set; }
public decimal? PowderReceivedLbs { get; set; }
// Pricing flags
/// <summary>
/// When true, the additional layer labor charge is not applied for this coat even if it is
/// not the first coat in the sequence. Used for clear coats, sealers, etc.
/// </summary>
public bool NoExtraLayerCharge { get; set; }
// Notes
public string? Notes { get; set; }
@@ -9,6 +9,8 @@ public class JobTemplateItem : BaseEntity
public int? CatalogItemId { get; set; }
public bool IsGenericItem { get; set; }
public bool IsLaborItem { get; set; }
public bool IsSalesItem { get; set; }
public string? Sku { get; set; }
public decimal? ManualUnitPrice { get; set; }
public bool RequiresSandblasting { get; set; }
public bool RequiresMasking { get; set; }
@@ -3,7 +3,6 @@ namespace PowderCoating.Core.Entities;
public class JobTimeEntry : BaseEntity
{
public int JobId { get; set; }
public int? ShopWorkerId { get; set; } // legacy — kept for entries created before user migration
public string? UserId { get; set; } // FK to AspNetUsers
public string? UserDisplayName { get; set; } // snapshot of worker name at entry creation time
public DateTime WorkDate { get; set; }
@@ -13,5 +12,4 @@ public class JobTimeEntry : BaseEntity
// Navigation
public virtual Job Job { get; set; } = null!;
public virtual ShopWorker? Worker { get; set; } // nullable — only populated for legacy entries
}
@@ -0,0 +1,54 @@
using PowderCoating.Core.Enums;
namespace PowderCoating.Core.Entities;
/// <summary>
/// Represents one customer self-service intake session — either completed on the front-desk tablet
/// (InPerson) or via an emailed link the customer fills out on their own device (Remote).
/// Sessions are tenant-scoped and soft-deletable. Load anonymous sessions with ignoreQueryFilters:true.
/// </summary>
public class KioskSession : BaseEntity
{
/// <summary>URL-safe GUID used in all kiosk routes; unique across the table.</summary>
public Guid SessionToken { get; set; } = Guid.NewGuid();
public KioskSessionType SessionType { get; set; }
public KioskSessionStatus Status { get; set; } = KioskSessionStatus.Active;
// ── Step 1 — Contact ─────────────────────────────────────────────────────
public string CustomerFirstName { get; set; } = string.Empty;
public string CustomerLastName { get; set; } = string.Empty;
public string CustomerPhone { get; set; } = string.Empty;
public string CustomerEmail { get; set; } = string.Empty;
public bool IsReturningCustomer { get; set; }
// ── Step 2 — Job Description ──────────────────────────────────────────────
public string JobDescription { get; set; } = string.Empty;
public string? HowDidYouHearAboutUs { get; set; }
// ── Step 3 — Terms & Consent ──────────────────────────────────────────────
public bool AgreedToTerms { get; set; }
public DateTime? AgreedToTermsAt { get; set; }
/// <summary>Customer opted in to SMS order updates; sets Customer.NotifyBySms on submission.</summary>
public bool SmsOptIn { get; set; }
/// <summary>Base-64 PNG from signature_pad; null for Remote sessions (no drawn signature required).</summary>
public string? SignatureDataBase64 { get; set; }
// ── Outcome ───────────────────────────────────────────────────────────────
public int? LinkedCustomerId { get; set; }
/// <summary>Set when KioskIntakeOutput = "Job". Null when a Quote was created instead.</summary>
public int? LinkedJobId { get; set; }
/// <summary>Set when KioskIntakeOutput = "Quote". Null when a Job was created instead.</summary>
public int? LinkedQuoteId { get; set; }
public DateTime? SubmittedAt { get; set; }
/// <summary>Sessions auto-expire 2 h after creation (InPerson) or 48 h (Remote). ExpiresAt is set at creation.</summary>
public DateTime ExpiresAt { get; set; }
// ── Remote-only ───────────────────────────────────────────────────────────
public string? RemoteLinkEmail { get; set; }
public DateTime? RemoteLinkSentAt { get; set; }
// ── Navigation ────────────────────────────────────────────────────────────
public virtual Customer? LinkedCustomer { get; set; }
public virtual Job? LinkedJob { get; set; }
}
@@ -18,6 +18,10 @@ public class Payment : BaseEntity
/// </summary>
public int? DepositAccountId { get; set; }
/// <summary>True once this payment has been matched against a bank statement during reconciliation.</summary>
public bool IsCleared { get; set; } = false;
public DateTime? ClearedDate { get; set; }
// Navigation
public virtual Invoice Invoice { get; set; } = null!;
public virtual ApplicationUser? RecordedBy { get; set; }
+26 -16
View File
@@ -17,6 +17,9 @@ public class Quote : BaseEntity
public string? ProspectCity { get; set; }
public string? ProspectState { get; set; }
public string? ProspectZipCode { get; set; }
// TCPA compliance: only true when staff explicitly records verbal SMS consent
public bool ProspectSmsConsent { get; set; } = false;
public DateTime? ProspectSmsConsentedAt { get; set; }
// Lookup foreign key (replacing enum)
public int QuoteStatusId { get; set; }
@@ -37,26 +40,33 @@ public class Quote : BaseEntity
public DateTime? ApprovedDate { get; set; }
// Pricing — all values are snapshots captured at save time and must not be recalculated on load
public decimal MaterialCosts { get; set; } // Sum of powder/material costs across all items
public decimal LaborCosts { get; set; } // Sum of labor costs across all items
public decimal EquipmentCosts { get; set; } // Sum of equipment costs across all items
public decimal ItemsSubtotal { get; set; } // Sum of item prices before any quote-level costs
public decimal OvenBatchCost { get; set; } // Oven batch charge applied at quote level
public decimal ShopSuppliesAmount { get; set; } // Shop supplies dollar amount
public decimal ShopSuppliesPercent { get; set; } // Shop supplies percentage used
public decimal OverheadAmount { get; set; } // Overhead dollar amount
public decimal OverheadPercent { get; set; } // Overhead percentage used
public decimal ProfitMargin { get; set; } // Profit margin dollar amount
public decimal ProfitPercent { get; set; } // Profit margin percentage used
public decimal SubTotal { get; set; } // SubtotalBeforeDiscount (items + oven + overhead + profit + shop supplies)
public decimal MaterialCosts { get; set; } // Sum of powder/material costs across all items
public decimal LaborCosts { get; set; } // Sum of labor costs across all items
public decimal EquipmentCosts { get; set; } // Sum of equipment costs across all items
public decimal ItemsSubtotal { get; set; } // Sum of item prices before any quote-level costs
public decimal OvenBatchCost { get; set; } // Oven batch charge applied at quote level
public decimal FacilityOverheadCost { get; set; } // Rent + utilities apportioned by estimated job hours
public decimal FacilityOverheadRatePerHour { get; set; }// Rate used for facility overhead ($/hr)
public decimal ShopSuppliesAmount { get; set; } // Shop supplies dollar amount
public decimal ShopSuppliesPercent { get; set; } // Shop supplies percentage used
public decimal OverheadAmount { get; set; } // Legacy overhead (now always 0; kept for migration safety)
public decimal OverheadPercent { get; set; } // Legacy overhead percent
public decimal ProfitMargin { get; set; } // Profit margin dollar amount (0 — baked into item prices)
public decimal ProfitPercent { get; set; } // Markup % used (for display reference)
public decimal SubTotal { get; set; } // SubtotalBeforeDiscount (items + oven + facility overhead + shop supplies)
// Discount Information
public DiscountType DiscountType { get; set; } = DiscountType.None;
public decimal DiscountValue { get; set; } = 0; // Value entered by user (percentage or fixed amount)
public decimal DiscountPercent { get; set; } // Calculated: actual percentage applied
public decimal DiscountAmount { get; set; } // Calculated: actual dollar amount deducted
public string? DiscountReason { get; set; } // Why discount was applied
public decimal DiscountValue { get; set; } = 0; // Value entered by user (percentage or fixed amount)
public decimal PricingTierDiscountAmount { get; set; } // Discount from customer's pricing tier
public decimal PricingTierDiscountPercent { get; set; } // Tier discount percentage
public decimal QuoteDiscountAmount { get; set; } // Manual quote-level discount amount
public decimal QuoteDiscountPercent { get; set; } // Manual quote-level discount percentage
public decimal DiscountPercent { get; set; } // Combined: actual percentage applied
public decimal DiscountAmount { get; set; } // Combined: actual dollar amount deducted
public string? DiscountReason { get; set; } // Why discount was applied
public bool HideDiscountFromCustomer { get; set; } = false; // Show only total on PDFs/portal
public decimal SubtotalAfterDiscount { get; set; } // SubTotal minus all discounts, before rush/tax
public decimal TaxPercent { get; set; }
public decimal TaxAmount { get; set; }
@@ -33,6 +33,13 @@ public class QuoteItemCoat : BaseEntity
public decimal CoatLaborCost { get; set; }
public decimal CoatTotalCost { get; set; }
// Pricing flags
/// <summary>
/// When true, the additional layer labor charge is not applied for this coat even if it is
/// not the first coat in the sequence. Used for clear coats, sealers, etc.
/// </summary>
public bool NoExtraLayerCharge { get; set; }
// Notes
public string? Notes { get; set; }
@@ -22,6 +22,10 @@ public class Refund : BaseEntity
public DateTime? IssuedDate { get; set; }
public string? IssuedById { get; set; }
/// <summary>Bank/checking account the refund was paid from. Mirrors Payment.DepositAccountId so
/// the Trial Balance can credit this account when computing bank balance.</summary>
public int? DepositAccountId { get; set; }
// For store-credit refunds: the CreditMemo created on their behalf
public int? CreditMemoId { get; set; }
@@ -1,18 +0,0 @@
using PowderCoating.Core.Enums;
namespace PowderCoating.Core.Entities;
public class ShopWorker : BaseEntity
{
public string Name { get; set; } = string.Empty;
public ShopWorkerRole Role { get; set; } = ShopWorkerRole.GeneralLabor;
public string? Phone { get; set; }
public string? Email { get; set; }
public bool IsActive { get; set; } = true;
public string? Notes { get; set; }
// Relationships
public virtual ICollection<Job> AssignedJobs { get; set; } = new List<Job>();
public virtual ICollection<MaintenanceRecord> AssignedMaintenanceTasks { get; set; } = new List<MaintenanceRecord>();
public virtual ICollection<JobTimeEntry> TimeEntries { get; set; } = new List<JobTimeEntry>();
}
@@ -1,15 +0,0 @@
using PowderCoating.Core.Enums;
namespace PowderCoating.Core.Entities;
/// <summary>
/// Optional per-role labor cost rate for job costing / profitability calculations.
/// If no rate is set for a role, the company's StandardLaborRate is used as fallback.
/// </summary>
public class ShopWorkerRoleCost : BaseEntity
{
public ShopWorkerRole Role { get; set; }
/// <summary>Cost (pay rate) per hour for this role — used in job costing, NOT billing.</summary>
public decimal HourlyRate { get; set; }
}
@@ -35,6 +35,10 @@ public class Vendor : BaseEntity
/// <summary>Default expense account pre-filled on new bill line items for this vendor.</summary>
public int? DefaultExpenseAccountId { get; set; }
// 1099 Contractor tracking
/// <summary>When true, this vendor is an independent contractor subject to 1099-NEC reporting.</summary>
public bool Is1099Vendor { get; set; } = false;
// Navigation
public virtual ICollection<InventoryItem> InventoryItems { get; set; } = new List<InventoryItem>();
public virtual ICollection<Bill> Bills { get; set; } = new List<Bill>();
@@ -13,6 +13,7 @@ public enum AccountType
public enum AccountSubType
{
// Assets
Cash = 8,
Checking = 1,
Savings = 2,
AccountsReceivable = 3,
@@ -65,3 +66,61 @@ public enum BillStatus
Paid = 3,
Voided = 4
}
/// <summary>
/// Company-level accounting method preference. Affects how financial reports
/// (P&amp;L, Balance Sheet, Cash Flow) query and present data. Switching this
/// setting never re-posts historical GL entries — it is a report-time choice only.
/// </summary>
public enum AccountingMethod
{
/// <summary>Revenue and expenses recognised when cash changes hands.</summary>
Cash = 0,
/// <summary>Revenue and expenses recognised when earned/incurred (default).</summary>
Accrual = 1
}
public enum BankReconciliationStatus
{
InProgress = 0,
Completed = 1
}
public enum VendorCreditStatus
{
Open = 0,
PartiallyApplied = 1,
Applied = 2,
Voided = 3
}
/// <summary>Source document type for a recurring template — controls which entity is created on each fire.</summary>
public enum RecurringTemplateType
{
/// <summary>Creates a vendor Bill (Draft, pending user review).</summary>
Bill = 1,
/// <summary>Creates a direct Expense entry (immediately recorded).</summary>
Expense = 2
}
/// <summary>How often a recurring template fires.</summary>
public enum RecurringFrequency
{
Daily = 1,
Weekly = 2,
BiWeekly = 3,
Monthly = 4,
Quarterly = 5,
Annually = 6
}
/// <summary>Lifecycle state of a Manual Journal Entry.</summary>
public enum JournalEntryStatus
{
/// <summary>Not yet posted — can still be edited or deleted.</summary>
Draft = 0,
/// <summary>Posted to the GL — immutable; can only be reversed.</summary>
Posted = 1,
/// <summary>A reversal JE has been created and posted for this entry.</summary>
Reversed = 2
}
-11
View File
@@ -78,17 +78,6 @@ public enum EquipmentStatus
Retired = 4
}
public enum ShopWorkerRole
{
GeneralLabor = 0,
Sandblaster = 1,
Coater = 2,
Masker = 3,
QualityControl = 4,
OvenOperator = 5,
Supervisor = 6,
Maintenance = 7
}
public enum JobPhotoType
{
@@ -0,0 +1,15 @@
namespace PowderCoating.Core.Enums;
public enum KioskSessionType
{
InPerson = 0,
Remote = 1
}
public enum KioskSessionStatus
{
Active = 0,
Submitted = 1,
Expired = 2,
Cancelled = 3
}
@@ -20,5 +20,7 @@ public enum NotificationType
SmsInboundStop = 12,
SmsInboundHelp = 13,
AdminEmail = 14,
SmsInboundStart = 15
SmsInboundStart = 15,
AppointmentReminder = 17,
AppointmentReminderStaff = 18
}
@@ -54,9 +54,7 @@ public interface IUnitOfWork : IDisposable
IRepository<AppointmentStatusLookup> AppointmentStatusLookups { get; }
IRepository<AppointmentTypeLookup> AppointmentTypeLookups { get; }
IRepository<PrepService> PrepServices { get; }
IRepository<ShopWorker> ShopWorkers { get; }
IRepository<ShopWorkerRoleCost> ShopWorkerRoleCosts { get; }
IRepository<ReworkRecord> ReworkRecords { get; }
IRepository<ReworkRecord> ReworkRecords { get; }
IRepository<Refund> Refunds { get; }
IRepository<CreditMemo> CreditMemos { get; }
IRepository<CreditMemoApplication> CreditMemoApplications { get; }
@@ -91,6 +89,35 @@ public interface IUnitOfWork : IDisposable
IRepository<BillPayment> BillPayments { get; }
IRepository<Expense> Expenses { get; }
// Manual Journal Entries
IRepository<JournalEntry> JournalEntries { get; }
IRepository<JournalEntryLine> JournalEntryLines { get; }
// Vendor Credits
IRepository<VendorCredit> VendorCredits { get; }
IRepository<VendorCreditLineItem> VendorCreditLineItems { get; }
IRepository<VendorCreditApplication> VendorCreditApplications { get; }
// Bank Reconciliation
IRepository<BankReconciliation> BankReconciliations { get; }
// Tax Rates
IRepository<TaxRate> TaxRates { get; }
// Recurring Transactions
IRepository<RecurringTemplate> RecurringTemplates { get; }
// Fixed Assets
IRepository<FixedAsset> FixedAssets { get; }
IRepository<FixedAssetDepreciationEntry> FixedAssetDepreciationEntries { get; }
// Budgeting
IRepository<Budget> Budgets { get; }
IRepository<BudgetLine> BudgetLines { get; }
// Year-End Close
IRepository<YearEndClose> YearEndCloses { get; }
// Notifications — typed repository for IgnoreQueryFilters-based history lookups
INotificationLogRepository NotificationLogs { get; }
IRepository<NotificationTemplate> NotificationTemplates { get; }
@@ -125,6 +152,9 @@ public interface IUnitOfWork : IDisposable
IRepository<GiftCertificate> GiftCertificates { get; }
IRepository<GiftCertificateRedemption> GiftCertificateRedemptions { get; }
// Customer Intake Kiosk
IRepository<KioskSession> KioskSessions { get; }
Task<int> SaveChangesAsync();
Task<int> CompleteAsync(); // Alias for SaveChangesAsync
@@ -85,4 +85,11 @@ public interface IJobRepository : IRepository<Job>
/// Returns null if not found or soft-deleted.
/// </summary>
Task<Job?> LoadForTemplateSnapshotAsync(int jobId);
/// <summary>
/// Returns all non-terminal jobs whose <c>ScheduledDate</c> is before today and not null,
/// ordered by scheduled date then job number. Used by the Daily Board to surface jobs that
/// were never completed and rolled past their scheduled day.
/// </summary>
Task<List<Job>> GetOverdueScheduledJobsAsync();
}
@@ -9,12 +9,17 @@ public record CompanyWizardInfo(bool Completed, DateTime? CompletedAt, string? C
/// <summary>
/// Per-company entity count summary used to populate the Index list without N+1 round-trips.
/// Also carries health-signal data (jobs30, jobs90, last login) so callers can compute a
/// <c>ChurnRisk</c> badge without a separate round-trip.
/// </summary>
public record CompanyCountSummary(
IReadOnlyDictionary<int, int> JobCounts,
IReadOnlyDictionary<int, int> QuoteCounts,
IReadOnlyDictionary<int, int> CustomerCounts,
IReadOnlyDictionary<int, CompanyWizardInfo> WizardInfo
IReadOnlyDictionary<int, CompanyWizardInfo> WizardInfo,
IReadOnlyDictionary<int, int> Jobs30Counts,
IReadOnlyDictionary<int, int> Jobs90Counts,
IReadOnlyDictionary<int, DateTime?> LastLoginDates
);
/// <summary>
@@ -26,10 +31,13 @@ public interface ICompanyListService
{
/// <summary>
/// Returns a paged, searched, and sorted slice of non-deleted companies together with the
/// total unfiltered count for pagination.
/// total count for pagination and the count of churned accounts that are currently hidden.
/// When <paramref name="hideChurned"/> is true, Expired/Canceled companies whose subscription
/// ended more than 14 days ago are excluded from results (but still counted for the banner).
/// </summary>
Task<(List<Company> Companies, int TotalCount)> GetPagedAsync(
string? searchTerm, string sortColumn, string sortDirection, int page, int pageSize);
Task<(List<Company> Companies, int TotalCount, int ChurnedCount)> GetPagedAsync(
string? searchTerm, string sortColumn, string sortDirection, int page, int pageSize,
bool hideChurned = true);
/// <summary>
/// Returns job, quote, customer, and wizard completion counts for each of the supplied
@@ -92,7 +92,11 @@ public class ApplicationDbContext : IdentityDbContext<ApplicationUser>, IDataPro
if (companyIdClaim != null && int.TryParse(companyIdClaim, out int companyId))
return companyId;
return null;
// Authenticated but CompanyId claim is missing or invalid.
// Return 0 (never a real company ID) so the global filter generates
// "CompanyId = 0" which matches nothing — prevents null-comparison
// ambiguity from leaking cross-tenant rows.
return 0;
}
}
@@ -129,8 +133,11 @@ public class ApplicationDbContext : IdentityDbContext<ApplicationUser>, IDataPro
{
get
{
// No HTTP context means background service, hosted service, or unit test — bypass tenant filter
if (_httpContextAccessor?.HttpContext == null) return true;
if (!IsSuperAdmin) return false;
return CurrentCompanyId == null || CurrentCompanyId == 1;
// CompanyId == 0 means no claim was present (break-glass / test SuperAdmins) — treat as platform admin
return CurrentCompanyId == null || CurrentCompanyId == 0 || CurrentCompanyId == 1;
}
}
@@ -205,11 +212,7 @@ public class ApplicationDbContext : IdentityDbContext<ApplicationUser>, IDataPro
public DbSet<MaintenanceRecord> MaintenanceRecords { get; set; }
/// <summary>Supplier/vendor records used by Purchasing and Accounts Payable; tenant-filtered with soft delete.</summary>
public DbSet<Vendor> Vendors { get; set; }
/// <summary>Shop worker profiles with role assignments; tenant-filtered with soft delete.</summary>
public DbSet<ShopWorker> ShopWorkers { get; set; }
/// <summary>Per-role labour cost rates used in pricing calculations; unique index on (CompanyId, Role).</summary>
public DbSet<ShopWorkerRoleCost> ShopWorkerRoleCosts { get; set; }
/// <summary>Rework records tracking quality failures and remediation work against a job; tenant-filtered with soft delete.</summary>
/// <summary>Rework records tracking quality failures and remediation work against a job; tenant-filtered with soft delete.</summary>
public DbSet<ReworkRecord> ReworkRecords { get; set; }
/// <summary>Customer refund records; tenant-filtered with soft delete.</summary>
public DbSet<Refund> Refunds { get; set; }
@@ -324,6 +327,39 @@ public class ApplicationDbContext : IdentityDbContext<ApplicationUser>, IDataPro
/// <summary>Ad-hoc expense records (non-bill spending); tenant-filtered with soft delete.</summary>
public DbSet<Expense> Expenses { get; set; }
/// <summary>Manual double-entry journal entries (Draft/Posted/Reversed lifecycle); tenant-filtered with soft delete.</summary>
public DbSet<JournalEntry> JournalEntries { get; set; }
/// <summary>Individual debit/credit lines within a journal entry; soft-delete only (access controlled through parent JournalEntry).</summary>
public DbSet<JournalEntryLine> JournalEntryLines { get; set; }
/// <summary>Bank reconciliation sessions matching GL transactions to bank statements; tenant-filtered with soft delete.</summary>
public DbSet<BankReconciliation> BankReconciliations { get; set; }
/// <summary>Named tax rates used to pre-fill invoice tax percent by jurisdiction; tenant-filtered with soft delete.</summary>
public DbSet<TaxRate> TaxRates { get; set; }
/// <summary>Recurring transaction templates that auto-generate bills or expenses on a schedule; tenant-filtered with soft delete.</summary>
public DbSet<RecurringTemplate> RecurringTemplates { get; set; }
/// <summary>Fixed assets subject to straight-line depreciation; tenant-filtered with soft delete.</summary>
public DbSet<FixedAsset> FixedAssets { get; set; }
/// <summary>One record per asset per period for each depreciation posting; soft-delete only.</summary>
public DbSet<FixedAssetDepreciationEntry> FixedAssetDepreciationEntries { get; set; }
/// <summary>Named annual budgets with monthly amounts per GL account; tenant-filtered with soft delete.</summary>
public DbSet<Budget> Budgets { get; set; }
/// <summary>One row per account per Budget; contains JanDec decimal columns.</summary>
public DbSet<BudgetLine> BudgetLines { get; set; }
/// <summary>Audit trail of completed year-end closes; tenant-filtered with soft delete.</summary>
public DbSet<YearEndClose> YearEndCloses { get; set; }
/// <summary>Credit notes received from vendors (returned goods, pricing disputes); tenant-filtered with soft delete.</summary>
public DbSet<VendorCredit> VendorCredits { get; set; }
/// <summary>Expense-reversal line items on a vendor credit; soft-delete only.</summary>
public DbSet<VendorCreditLineItem> VendorCreditLineItems { get; set; }
/// <summary>Application records linking a vendor credit to a specific bill; soft-delete only.</summary>
public DbSet<VendorCreditApplication> VendorCreditApplications { get; set; }
// Job Templates
/// <summary>Reusable job templates that pre-populate job items, coats, and prep services on job creation.</summary>
public DbSet<JobTemplate> JobTemplates { get; set; }
@@ -334,6 +370,10 @@ public class ApplicationDbContext : IdentityDbContext<ApplicationUser>, IDataPro
/// <summary>Prep-service definitions within a job template item.</summary>
public DbSet<JobTemplateItemPrepService> JobTemplateItemPrepServices { get; set; }
// Customer Intake Kiosk
/// <summary>Customer self-service intake sessions (walk-in tablet or remote email link); tenant-filtered with soft delete.</summary>
public DbSet<KioskSession> KioskSessions { get; set; }
/// <summary>
/// Platform-wide audit log capturing who changed what and when, across all tenants.
/// No global query filter — SuperAdmin controllers query this directly.
@@ -493,11 +533,7 @@ public class ApplicationDbContext : IdentityDbContext<ApplicationUser>, IDataPro
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
modelBuilder.Entity<JobStatusHistory>().HasQueryFilter(e =>
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
modelBuilder.Entity<ShopWorker>().HasQueryFilter(e =>
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
modelBuilder.Entity<ShopWorkerRoleCost>().HasQueryFilter(e =>
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
modelBuilder.Entity<ReworkRecord>().HasQueryFilter(e =>
modelBuilder.Entity<ReworkRecord>().HasQueryFilter(e =>
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
modelBuilder.Entity<Refund>().HasQueryFilter(e =>
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
@@ -614,6 +650,93 @@ public class ApplicationDbContext : IdentityDbContext<ApplicationUser>, IDataPro
modelBuilder.Entity<Expense>().HasQueryFilter(e =>
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
// Journal Entries: tenant-filtered; lines use soft-delete only (child rows)
modelBuilder.Entity<JournalEntry>().HasQueryFilter(e =>
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
modelBuilder.Entity<JournalEntryLine>().HasQueryFilter(e => !e.IsDeleted);
// Bank Reconciliation: tenant-filtered
modelBuilder.Entity<BankReconciliation>().HasQueryFilter(e =>
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
// Tax Rates: tenant-filtered
modelBuilder.Entity<TaxRate>().HasQueryFilter(e =>
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
// Recurring Templates: tenant-filtered
modelBuilder.Entity<RecurringTemplate>().HasQueryFilter(e =>
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
// Fixed Assets: tenant-filtered with soft delete; depreciation entries soft-delete only
modelBuilder.Entity<FixedAsset>().HasQueryFilter(e =>
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
modelBuilder.Entity<FixedAssetDepreciationEntry>().HasQueryFilter(e => !e.IsDeleted);
// FixedAsset → Account (three FKs): NoAction to avoid cascade conflicts; Account has no
// reverse collection for FixedAssets so WithMany() is anonymous for each.
modelBuilder.Entity<FixedAsset>()
.HasOne(fa => fa.AssetAccount)
.WithMany()
.HasForeignKey(fa => fa.AssetAccountId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<FixedAsset>()
.HasOne(fa => fa.DepreciationExpenseAccount)
.WithMany()
.HasForeignKey(fa => fa.DepreciationExpenseAccountId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<FixedAsset>()
.HasOne(fa => fa.AccumDepreciationAccount)
.WithMany()
.HasForeignKey(fa => fa.AccumDepreciationAccountId)
.OnDelete(DeleteBehavior.Restrict);
// FixedAssetDepreciationEntry → JournalEntry: NoAction (entries outlive their JE)
modelBuilder.Entity<FixedAssetDepreciationEntry>()
.HasOne(e => e.JournalEntry)
.WithMany()
.HasForeignKey(e => e.JournalEntryId)
.OnDelete(DeleteBehavior.NoAction);
// Budgets: tenant-filtered; BudgetLines soft-delete only
modelBuilder.Entity<Budget>().HasQueryFilter(e =>
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
modelBuilder.Entity<BudgetLine>().HasQueryFilter(e => !e.IsDeleted);
// BudgetLine → Account: Restrict delete so removing an account doesn't cascade into budget data
modelBuilder.Entity<BudgetLine>()
.HasOne(bl => bl.Account)
.WithMany()
.HasForeignKey(bl => bl.AccountId)
.OnDelete(DeleteBehavior.Restrict);
// YearEndClose: tenant-filtered; links to a specific JE
modelBuilder.Entity<YearEndClose>().HasQueryFilter(e =>
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
modelBuilder.Entity<YearEndClose>()
.HasOne(y => y.JournalEntry)
.WithMany()
.HasForeignKey(y => y.JournalEntryId)
.OnDelete(DeleteBehavior.Restrict);
// Vendor Credits: tenant-filtered; child rows soft-delete only
modelBuilder.Entity<VendorCredit>().HasQueryFilter(e =>
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
modelBuilder.Entity<VendorCreditLineItem>().HasQueryFilter(e => !e.IsDeleted);
modelBuilder.Entity<VendorCreditApplication>().HasQueryFilter(e => !e.IsDeleted);
// VendorCreditApplication: NoAction on both FKs to avoid SQL Server multiple-cascade-path error 1785.
// Bills and VendorCredits both cascade-delete through Vendor, creating two paths to VendorCreditApplications.
modelBuilder.Entity<VendorCreditApplication>()
.HasOne(vca => vca.Bill)
.WithMany()
.HasForeignKey(vca => vca.BillId)
.OnDelete(DeleteBehavior.NoAction);
modelBuilder.Entity<VendorCreditApplication>()
.HasOne(vca => vca.VendorCredit)
.WithMany(vc => vc.Applications)
.HasForeignKey(vca => vca.VendorCreditId)
.OnDelete(DeleteBehavior.NoAction);
// Purchase Orders
modelBuilder.Entity<PurchaseOrder>().HasQueryFilter(e =>
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
@@ -626,6 +749,24 @@ public class ApplicationDbContext : IdentityDbContext<ApplicationUser>, IDataPro
modelBuilder.Entity<InAppNotification>().HasQueryFilter(e =>
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
// Customer intake kiosk sessions — tenant-filtered + soft delete.
// Anonymous intake routes must use ignoreQueryFilters:true when loading by SessionToken.
modelBuilder.Entity<KioskSession>().HasQueryFilter(e =>
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
modelBuilder.Entity<KioskSession>()
.HasIndex(e => e.SessionToken)
.IsUnique();
modelBuilder.Entity<KioskSession>()
.HasOne(k => k.LinkedCustomer)
.WithMany()
.HasForeignKey(k => k.LinkedCustomerId)
.OnDelete(DeleteBehavior.SetNull);
modelBuilder.Entity<KioskSession>()
.HasOne(k => k.LinkedJob)
.WithMany()
.HasForeignKey(k => k.LinkedJobId)
.OnDelete(DeleteBehavior.SetNull);
// Account self-referencing hierarchy
modelBuilder.Entity<Account>()
.HasOne(a => a.ParentAccount)
@@ -633,6 +774,34 @@ public class ApplicationDbContext : IdentityDbContext<ApplicationUser>, IDataPro
.HasForeignKey(a => a.ParentAccountId)
.OnDelete(DeleteBehavior.Restrict);
// JournalEntry self-referencing reversal link
modelBuilder.Entity<JournalEntry>()
.HasOne(je => je.ReversalOf)
.WithMany()
.HasForeignKey(je => je.ReversalOfId)
.OnDelete(DeleteBehavior.Restrict);
// BankReconciliation → Account (no cascade)
modelBuilder.Entity<BankReconciliation>()
.HasOne(br => br.Account)
.WithMany()
.HasForeignKey(br => br.AccountId)
.OnDelete(DeleteBehavior.Restrict);
// VendorCredit → APAccount (no cascade)
modelBuilder.Entity<VendorCredit>()
.HasOne(vc => vc.APAccount)
.WithMany()
.HasForeignKey(vc => vc.APAccountId)
.OnDelete(DeleteBehavior.Restrict);
// VendorCreditLineItem → Account (nullable, no cascade)
modelBuilder.Entity<VendorCreditLineItem>()
.HasOne(li => li.Account)
.WithMany()
.HasForeignKey(li => li.AccountId)
.OnDelete(DeleteBehavior.Restrict);
// Vendor → DefaultExpenseAccount (no cascade)
modelBuilder.Entity<Vendor>()
.HasOne(s => s.DefaultExpenseAccount)
@@ -1144,12 +1313,7 @@ public class ApplicationDbContext : IdentityDbContext<ApplicationUser>, IDataPro
.HasForeignKey(m => m.PerformedById)
.OnDelete(DeleteBehavior.SetNull);
// ShopWorker relationships
modelBuilder.Entity<ShopWorker>()
.HasOne<Company>()
.WithMany(c => c.ShopWorkers)
.HasForeignKey(e => e.CompanyId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<Job>()
.HasOne(j => j.AssignedUser)
@@ -1223,10 +1387,7 @@ public class ApplicationDbContext : IdentityDbContext<ApplicationUser>, IDataPro
modelBuilder.Entity<PricingTier>()
.HasIndex(p => p.CompanyId);
modelBuilder.Entity<ShopWorker>()
.HasIndex(w => w.CompanyId);
modelBuilder.Entity<CatalogCategory>()
modelBuilder.Entity<CatalogCategory>()
.HasIndex(c => c.CompanyId);
modelBuilder.Entity<CatalogCategory>()
@@ -1261,12 +1422,7 @@ public class ApplicationDbContext : IdentityDbContext<ApplicationUser>, IDataPro
.IsUnique()
.HasDatabaseName("IX_Jobs_CompanyId_JobNumber");
modelBuilder.Entity<ShopWorkerRoleCost>()
.HasIndex(r => new { r.CompanyId, r.Role })
.IsUnique()
.HasDatabaseName("IX_ShopWorkerRoleCosts_CompanyId_Role");
modelBuilder.Entity<Job>()
modelBuilder.Entity<Job>()
.Property(j => j.ShopAccessCode)
.HasDefaultValueSql("NEWID()");
@@ -21,7 +21,7 @@ public class AuditInterceptor : SaveChangesInterceptor
private static readonly HashSet<string> AuditedTypes = new(StringComparer.Ordinal)
{
nameof(Customer), nameof(Job), nameof(Quote), nameof(Equipment),
nameof(MaintenanceRecord), nameof(Vendor), nameof(ShopWorker),
nameof(MaintenanceRecord), nameof(Vendor),
nameof(InventoryItem), nameof(Company),
// Financial entities
nameof(Invoice), nameof(Payment), nameof(Bill), nameof(BillPayment),
@@ -967,6 +967,17 @@ New accounts walk through an 18-step setup wizard to configure company informati
CreatedAt = DateTime.UtcNow
},
new NotificationTemplate
{
NotificationType = NotificationType.InvoiceSent,
Channel = NotificationChannel.Sms,
DisplayName = "Invoice Sent (SMS)",
Subject = null,
Body = "{{companyName}}: Invoice {{invoiceNumber}} for {{invoiceTotal}} is ready. View your invoice: {{viewUrl}} Reply STOP to opt out.",
IsActive = true,
CompanyId = companyId,
CreatedAt = DateTime.UtcNow
},
new NotificationTemplate
{
NotificationType = NotificationType.PaymentReceived,
Channel = NotificationChannel.Email,
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -11,26 +11,20 @@ namespace PowderCoating.Infrastructure.Migrations
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 1,
column: "CreatedAt",
value: new DateTime(2026, 3, 16, 15, 49, 58, 737, DateTimeKind.Utc).AddTicks(7851));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 2,
column: "CreatedAt",
value: new DateTime(2026, 3, 16, 15, 49, 58, 737, DateTimeKind.Utc).AddTicks(7856));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 3,
column: "CreatedAt",
value: new DateTime(2026, 3, 16, 15, 49, 58, 737, DateTimeKind.Utc).AddTicks(7858));
// These UpdateData calls were generated from an existing live database.
// On a fresh install the PricingTiers table and its seed rows may not exist yet
// (seeding is manual via Platform Management → Seed Data), so guard each update.
migrationBuilder.Sql(@"
IF OBJECT_ID(N'[PricingTiers]', N'U') IS NOT NULL
BEGIN
IF EXISTS (SELECT 1 FROM [PricingTiers] WHERE [Id] = 1)
UPDATE [PricingTiers] SET [CreatedAt] = '2026-03-16T15:49:58.7377851Z' WHERE [Id] = 1;
IF EXISTS (SELECT 1 FROM [PricingTiers] WHERE [Id] = 2)
UPDATE [PricingTiers] SET [CreatedAt] = '2026-03-16T15:49:58.7377856Z' WHERE [Id] = 2;
IF EXISTS (SELECT 1 FROM [PricingTiers] WHERE [Id] = 3)
UPDATE [PricingTiers] SET [CreatedAt] = '2026-03-16T15:49:58.7377858Z' WHERE [Id] = 3;
END
");
}
/// <inheritdoc />
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,71 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace PowderCoating.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class AddCustomerBillingEmail : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "BillingEmail",
table: "Customers",
type: "nvarchar(max)",
nullable: true);
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 1,
column: "CreatedAt",
value: new DateTime(2026, 5, 7, 22, 47, 45, 755, DateTimeKind.Utc).AddTicks(648));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 2,
column: "CreatedAt",
value: new DateTime(2026, 5, 7, 22, 47, 45, 755, DateTimeKind.Utc).AddTicks(653));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 3,
column: "CreatedAt",
value: new DateTime(2026, 5, 7, 22, 47, 45, 755, DateTimeKind.Utc).AddTicks(655));
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "BillingEmail",
table: "Customers");
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 1,
column: "CreatedAt",
value: new DateTime(2026, 5, 6, 19, 59, 56, 264, DateTimeKind.Utc).AddTicks(846));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 2,
column: "CreatedAt",
value: new DateTime(2026, 5, 6, 19, 59, 56, 264, DateTimeKind.Utc).AddTicks(852));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 3,
column: "CreatedAt",
value: new DateTime(2026, 5, 6, 19, 59, 56, 264, DateTimeKind.Utc).AddTicks(853));
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,82 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace PowderCoating.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class AddProspectSmsConsent : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "ProspectSmsConsent",
table: "Quotes",
type: "bit",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<DateTime>(
name: "ProspectSmsConsentedAt",
table: "Quotes",
type: "datetime2",
nullable: true);
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 1,
column: "CreatedAt",
value: new DateTime(2026, 5, 8, 0, 24, 28, 872, DateTimeKind.Utc).AddTicks(5347));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 2,
column: "CreatedAt",
value: new DateTime(2026, 5, 8, 0, 24, 28, 872, DateTimeKind.Utc).AddTicks(5357));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 3,
column: "CreatedAt",
value: new DateTime(2026, 5, 8, 0, 24, 28, 872, DateTimeKind.Utc).AddTicks(5358));
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "ProspectSmsConsent",
table: "Quotes");
migrationBuilder.DropColumn(
name: "ProspectSmsConsentedAt",
table: "Quotes");
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 1,
column: "CreatedAt",
value: new DateTime(2026, 5, 7, 22, 47, 45, 755, DateTimeKind.Utc).AddTicks(648));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 2,
column: "CreatedAt",
value: new DateTime(2026, 5, 7, 22, 47, 45, 755, DateTimeKind.Utc).AddTicks(653));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 3,
column: "CreatedAt",
value: new DateTime(2026, 5, 7, 22, 47, 45, 755, DateTimeKind.Utc).AddTicks(655));
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,72 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace PowderCoating.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class AddInventoryIsIncoming : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "IsIncoming",
table: "InventoryItems",
type: "bit",
nullable: false,
defaultValue: false);
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 1,
column: "CreatedAt",
value: new DateTime(2026, 5, 8, 1, 37, 3, 534, DateTimeKind.Utc).AddTicks(1857));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 2,
column: "CreatedAt",
value: new DateTime(2026, 5, 8, 1, 37, 3, 534, DateTimeKind.Utc).AddTicks(1863));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 3,
column: "CreatedAt",
value: new DateTime(2026, 5, 8, 1, 37, 3, 534, DateTimeKind.Utc).AddTicks(1865));
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "IsIncoming",
table: "InventoryItems");
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 1,
column: "CreatedAt",
value: new DateTime(2026, 5, 8, 0, 24, 28, 872, DateTimeKind.Utc).AddTicks(5347));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 2,
column: "CreatedAt",
value: new DateTime(2026, 5, 8, 0, 24, 28, 872, DateTimeKind.Utc).AddTicks(5357));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 3,
column: "CreatedAt",
value: new DateTime(2026, 5, 8, 0, 24, 28, 872, DateTimeKind.Utc).AddTicks(5358));
}
}
}
@@ -0,0 +1,83 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace PowderCoating.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class AddShopSuppliesAmountToJob : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<decimal>(
name: "ShopSuppliesAmount",
table: "Jobs",
type: "decimal(18,2)",
nullable: false,
defaultValue: 0m);
migrationBuilder.AddColumn<decimal>(
name: "ShopSuppliesPercent",
table: "Jobs",
type: "decimal(18,2)",
nullable: false,
defaultValue: 0m);
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 1,
column: "CreatedAt",
value: new DateTime(2026, 5, 8, 14, 21, 51, 589, DateTimeKind.Utc).AddTicks(4358));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 2,
column: "CreatedAt",
value: new DateTime(2026, 5, 8, 14, 21, 51, 589, DateTimeKind.Utc).AddTicks(4424));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 3,
column: "CreatedAt",
value: new DateTime(2026, 5, 8, 14, 21, 51, 589, DateTimeKind.Utc).AddTicks(4426));
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "ShopSuppliesAmount",
table: "Jobs");
migrationBuilder.DropColumn(
name: "ShopSuppliesPercent",
table: "Jobs");
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 1,
column: "CreatedAt",
value: new DateTime(2026, 5, 8, 1, 37, 3, 534, DateTimeKind.Utc).AddTicks(1857));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 2,
column: "CreatedAt",
value: new DateTime(2026, 5, 8, 1, 37, 3, 534, DateTimeKind.Utc).AddTicks(1863));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 3,
column: "CreatedAt",
value: new DateTime(2026, 5, 8, 1, 37, 3, 534, DateTimeKind.Utc).AddTicks(1865));
}
}
}
@@ -0,0 +1,82 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace PowderCoating.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class AddJobTemplateItemSalesFields : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "IsSalesItem",
table: "JobTemplateItems",
type: "bit",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<string>(
name: "Sku",
table: "JobTemplateItems",
type: "nvarchar(max)",
nullable: true);
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 1,
column: "CreatedAt",
value: new DateTime(2026, 5, 10, 1, 12, 48, 386, DateTimeKind.Utc).AddTicks(2249));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 2,
column: "CreatedAt",
value: new DateTime(2026, 5, 10, 1, 12, 48, 386, DateTimeKind.Utc).AddTicks(2260));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 3,
column: "CreatedAt",
value: new DateTime(2026, 5, 10, 1, 12, 48, 386, DateTimeKind.Utc).AddTicks(2261));
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "IsSalesItem",
table: "JobTemplateItems");
migrationBuilder.DropColumn(
name: "Sku",
table: "JobTemplateItems");
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 1,
column: "CreatedAt",
value: new DateTime(2026, 5, 8, 14, 21, 51, 589, DateTimeKind.Utc).AddTicks(4358));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 2,
column: "CreatedAt",
value: new DateTime(2026, 5, 8, 14, 21, 51, 589, DateTimeKind.Utc).AddTicks(4424));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 3,
column: "CreatedAt",
value: new DateTime(2026, 5, 8, 14, 21, 51, 589, DateTimeKind.Utc).AddTicks(4426));
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,72 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace PowderCoating.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class AddAccountingMethod : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<int>(
name: "AccountingMethod",
table: "Companies",
type: "int",
nullable: false,
defaultValue: 1); // 1 = Accrual (default for new and existing companies)
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 1,
column: "CreatedAt",
value: new DateTime(2026, 5, 10, 3, 25, 9, 644, DateTimeKind.Utc).AddTicks(9957));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 2,
column: "CreatedAt",
value: new DateTime(2026, 5, 10, 3, 25, 9, 644, DateTimeKind.Utc).AddTicks(9963));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 3,
column: "CreatedAt",
value: new DateTime(2026, 5, 10, 3, 25, 9, 644, DateTimeKind.Utc).AddTicks(9965));
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "AccountingMethod",
table: "Companies");
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 1,
column: "CreatedAt",
value: new DateTime(2026, 5, 10, 1, 12, 48, 386, DateTimeKind.Utc).AddTicks(2249));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 2,
column: "CreatedAt",
value: new DateTime(2026, 5, 10, 1, 12, 48, 386, DateTimeKind.Utc).AddTicks(2260));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 3,
column: "CreatedAt",
value: new DateTime(2026, 5, 10, 1, 12, 48, 386, DateTimeKind.Utc).AddTicks(2261));
}
}
}
File diff suppressed because it is too large Load Diff

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