Compare commits

..

71 Commits

Author SHA1 Message Date
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
347 changed files with 201835 additions and 2734 deletions
+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
@@ -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; }
}
@@ -6,6 +6,92 @@ namespace PowderCoating.Application.DTOs.Accounting;
// 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
@@ -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";
}
@@ -140,12 +140,12 @@ 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
@@ -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; }
}
@@ -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; }
@@ -515,6 +515,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);
}
@@ -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;
}
@@ -258,4 +266,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);
}
@@ -35,4 +35,18 @@ public interface IFinancialReportService
/// <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);
}
@@ -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, string? overrideEmail = 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.
@@ -44,10 +44,17 @@ public interface IPdfService
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);
}
@@ -54,5 +54,6 @@ public class CompanyProfile : Profile
CreateMap<UpdateQuoteTemplateDto, CompanyPreferences>();
CreateMap<UpdateInvoiceTemplateDto, CompanyPreferences>();
CreateMap<UpdateWorkOrderTemplateDto, CompanyPreferences>();
CreateMap<UpdateKioskSettingsDto, CompanyPreferences>();
}
}
@@ -28,7 +28,9 @@ public class InvoiceProfile : Profile
? (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))
@@ -21,6 +21,7 @@ public class JobItemAssemblyService : IJobItemAssemblyService
IsGenericItem = source.IsGenericItem,
IsLaborItem = source.IsLaborItem,
IsSalesItem = source.IsSalesItem,
IsAiItem = source.IsAiItem,
Sku = source.Sku,
ManualUnitPrice = source.ManualUnitPrice,
PowderCostOverride = source.PowderCostOverride,
@@ -106,6 +107,7 @@ public class JobItemAssemblyService : IJobItemAssemblyService
IsGenericItem = source.IsGenericItem,
IsLaborItem = source.IsLaborItem,
IsSalesItem = source.IsSalesItem,
IsAiItem = source.IsAiItem,
Sku = source.Sku,
ManualUnitPrice = source.ManualUnitPrice,
PowderCostOverride = source.PowderCostOverride,
@@ -191,6 +193,7 @@ public class JobItemAssemblyService : IJobItemAssemblyService
IsGenericItem = source.IsGenericItem,
IsLaborItem = source.IsLaborItem,
IsSalesItem = source.IsSalesItem,
IsAiItem = source.IsAiItem,
Sku = source.Sku,
ManualUnitPrice = source.ManualUnitPrice,
PowderCostOverride = source.PowderCostOverride,
@@ -270,6 +273,7 @@ public class JobItemAssemblyService : IJobItemAssemblyService
IsGenericItem = seed.IsGenericItem,
IsLaborItem = seed.IsLaborItem,
IsSalesItem = seed.IsSalesItem,
IsAiItem = seed.IsAiItem,
Sku = seed.Sku,
ManualUnitPrice = seed.ManualUnitPrice,
PowderCostOverride = seed.PowderCostOverride,
@@ -364,6 +368,7 @@ public class JobItemAssemblyService : IJobItemAssemblyService
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; }
@@ -1858,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
@@ -2593,4 +2637,120 @@ public class PdfService : IPdfService
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);
}
}
}
@@ -590,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
// 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);
}
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);
}
itemResults.Add(itemResult);
}
@@ -104,8 +104,11 @@ public class QuotePricingAssemblyService : IQuotePricingAssemblyService
var catalogItem = await _unitOfWork.CatalogItems.GetByIdAsync(itemDto.CatalogItemId.Value);
if (catalogItem != null)
{
item.UnitPrice = catalogItem.DefaultPrice;
item.TotalPrice = catalogItem.DefaultPrice * itemDto.Quantity;
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);
}
@@ -246,6 +246,8 @@ public class VendorCredit : BaseEntity
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!;
@@ -285,3 +287,172 @@ public class VendorCreditApplication : BaseEntity
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,6 +50,8 @@ 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")
@@ -112,11 +112,27 @@ public class Company : BaseEntity
/// </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>();
@@ -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; }
@@ -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; }
@@ -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.
+5
View File
@@ -25,9 +25,14 @@ 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; }
@@ -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; }
@@ -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; }
}
@@ -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; }
@@ -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>();
@@ -94,6 +94,26 @@ public enum VendorCreditStatus
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
{
@@ -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
}
@@ -103,6 +103,23 @@ public interface IUnitOfWork : IDisposable
// 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; }
@@ -137,6 +154,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
@@ -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
@@ -332,6 +332,24 @@ public class ApplicationDbContext : IdentityDbContext<ApplicationUser>, IDataPro
/// <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>
@@ -349,6 +367,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.
@@ -638,12 +660,84 @@ public class ApplicationDbContext : IdentityDbContext<ApplicationUser>, IDataPro
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));
@@ -656,6 +750,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)
@@ -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,
@@ -78,13 +78,13 @@ namespace PowderCoating.Infrastructure.Migrations
column: x => x.BillId,
principalTable: "Bills",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
onDelete: ReferentialAction.NoAction);
table.ForeignKey(
name: "FK_VendorCreditApplications_VendorCredits_VendorCreditId",
column: x => x.VendorCreditId,
principalTable: "VendorCredits",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
onDelete: ReferentialAction.NoAction);
});
migrationBuilder.CreateTable(
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,112 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace PowderCoating.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class AddPaymentTermsAndTaxRates : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<int>(
name: "EarlyPaymentDiscountDays",
table: "Invoices",
type: "int",
nullable: false,
defaultValue: 0);
migrationBuilder.AddColumn<decimal>(
name: "EarlyPaymentDiscountPercent",
table: "Invoices",
type: "decimal(18,2)",
nullable: false,
defaultValue: 0m);
migrationBuilder.CreateTable(
name: "TaxRates",
columns: table => new
{
Id = table.Column<int>(type: "int", nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
Name = table.Column<string>(type: "nvarchar(max)", nullable: false),
Rate = table.Column<decimal>(type: "decimal(18,2)", nullable: false),
State = table.Column<string>(type: "nvarchar(max)", nullable: true),
Description = table.Column<string>(type: "nvarchar(max)", nullable: true),
IsDefault = table.Column<bool>(type: "bit", nullable: false),
IsActive = table.Column<bool>(type: "bit", nullable: false),
CompanyId = table.Column<int>(type: "int", nullable: false),
CreatedAt = table.Column<DateTime>(type: "datetime2", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
CreatedBy = table.Column<string>(type: "nvarchar(max)", nullable: true),
UpdatedBy = table.Column<string>(type: "nvarchar(max)", nullable: true),
IsDeleted = table.Column<bool>(type: "bit", nullable: false),
DeletedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
DeletedBy = table.Column<string>(type: "nvarchar(max)", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_TaxRates", x => x.Id);
});
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 1,
column: "CreatedAt",
value: new DateTime(2026, 5, 10, 14, 48, 51, 545, DateTimeKind.Utc).AddTicks(3903));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 2,
column: "CreatedAt",
value: new DateTime(2026, 5, 10, 14, 48, 51, 545, DateTimeKind.Utc).AddTicks(3909));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 3,
column: "CreatedAt",
value: new DateTime(2026, 5, 10, 14, 48, 51, 545, DateTimeKind.Utc).AddTicks(3910));
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "TaxRates");
migrationBuilder.DropColumn(
name: "EarlyPaymentDiscountDays",
table: "Invoices");
migrationBuilder.DropColumn(
name: "EarlyPaymentDiscountPercent",
table: "Invoices");
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 1,
column: "CreatedAt",
value: new DateTime(2026, 5, 10, 4, 6, 6, 200, DateTimeKind.Utc).AddTicks(8472));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 2,
column: "CreatedAt",
value: new DateTime(2026, 5, 10, 4, 6, 6, 200, DateTimeKind.Utc).AddTicks(8478));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 3,
column: "CreatedAt",
value: new DateTime(2026, 5, 10, 4, 6, 6, 200, DateTimeKind.Utc).AddTicks(8479));
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,171 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace PowderCoating.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class AddRecurringTemplates : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_VendorCreditApplications_Bills_BillId",
table: "VendorCreditApplications");
migrationBuilder.DropForeignKey(
name: "FK_VendorCreditApplications_VendorCredits_VendorCreditId",
table: "VendorCreditApplications");
migrationBuilder.AddColumn<int>(
name: "VendorCreditId1",
table: "VendorCreditApplications",
type: "int",
nullable: true);
migrationBuilder.CreateTable(
name: "RecurringTemplates",
columns: table => new
{
Id = table.Column<int>(type: "int", nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
Name = table.Column<string>(type: "nvarchar(max)", nullable: false),
TemplateType = table.Column<int>(type: "int", nullable: false),
Frequency = table.Column<int>(type: "int", nullable: false),
IntervalCount = table.Column<int>(type: "int", nullable: false),
NextFireDate = table.Column<DateTime>(type: "datetime2", nullable: false),
EndDate = table.Column<DateTime>(type: "datetime2", nullable: true),
MaxOccurrences = table.Column<int>(type: "int", nullable: true),
OccurrenceCount = table.Column<int>(type: "int", nullable: false),
IsActive = table.Column<bool>(type: "bit", nullable: false),
TemplateData = table.Column<string>(type: "nvarchar(max)", nullable: false),
LastError = table.Column<string>(type: "nvarchar(max)", nullable: true),
CompanyId = table.Column<int>(type: "int", nullable: false),
CreatedAt = table.Column<DateTime>(type: "datetime2", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
CreatedBy = table.Column<string>(type: "nvarchar(max)", nullable: true),
UpdatedBy = table.Column<string>(type: "nvarchar(max)", nullable: true),
IsDeleted = table.Column<bool>(type: "bit", nullable: false),
DeletedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
DeletedBy = table.Column<string>(type: "nvarchar(max)", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_RecurringTemplates", x => x.Id);
});
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 1,
column: "CreatedAt",
value: new DateTime(2026, 5, 10, 15, 1, 54, 769, DateTimeKind.Utc).AddTicks(6262));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 2,
column: "CreatedAt",
value: new DateTime(2026, 5, 10, 15, 1, 54, 769, DateTimeKind.Utc).AddTicks(6270));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 3,
column: "CreatedAt",
value: new DateTime(2026, 5, 10, 15, 1, 54, 769, DateTimeKind.Utc).AddTicks(6271));
migrationBuilder.CreateIndex(
name: "IX_VendorCreditApplications_VendorCreditId1",
table: "VendorCreditApplications",
column: "VendorCreditId1");
migrationBuilder.AddForeignKey(
name: "FK_VendorCreditApplications_Bills_BillId",
table: "VendorCreditApplications",
column: "BillId",
principalTable: "Bills",
principalColumn: "Id");
migrationBuilder.AddForeignKey(
name: "FK_VendorCreditApplications_VendorCredits_VendorCreditId",
table: "VendorCreditApplications",
column: "VendorCreditId",
principalTable: "VendorCredits",
principalColumn: "Id");
migrationBuilder.AddForeignKey(
name: "FK_VendorCreditApplications_VendorCredits_VendorCreditId1",
table: "VendorCreditApplications",
column: "VendorCreditId1",
principalTable: "VendorCredits",
principalColumn: "Id");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_VendorCreditApplications_Bills_BillId",
table: "VendorCreditApplications");
migrationBuilder.DropForeignKey(
name: "FK_VendorCreditApplications_VendorCredits_VendorCreditId",
table: "VendorCreditApplications");
migrationBuilder.DropForeignKey(
name: "FK_VendorCreditApplications_VendorCredits_VendorCreditId1",
table: "VendorCreditApplications");
migrationBuilder.DropTable(
name: "RecurringTemplates");
migrationBuilder.DropIndex(
name: "IX_VendorCreditApplications_VendorCreditId1",
table: "VendorCreditApplications");
migrationBuilder.DropColumn(
name: "VendorCreditId1",
table: "VendorCreditApplications");
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 1,
column: "CreatedAt",
value: new DateTime(2026, 5, 10, 14, 48, 51, 545, DateTimeKind.Utc).AddTicks(3903));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 2,
column: "CreatedAt",
value: new DateTime(2026, 5, 10, 14, 48, 51, 545, DateTimeKind.Utc).AddTicks(3909));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 3,
column: "CreatedAt",
value: new DateTime(2026, 5, 10, 14, 48, 51, 545, DateTimeKind.Utc).AddTicks(3910));
migrationBuilder.AddForeignKey(
name: "FK_VendorCreditApplications_Bills_BillId",
table: "VendorCreditApplications",
column: "BillId",
principalTable: "Bills",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
migrationBuilder.AddForeignKey(
name: "FK_VendorCreditApplications_VendorCredits_VendorCreditId",
table: "VendorCreditApplications",
column: "VendorCreditId",
principalTable: "VendorCredits",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,91 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace PowderCoating.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class DropOrphanVendorCreditId1 : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_VendorCreditApplications_VendorCredits_VendorCreditId1",
table: "VendorCreditApplications");
migrationBuilder.DropIndex(
name: "IX_VendorCreditApplications_VendorCreditId1",
table: "VendorCreditApplications");
migrationBuilder.DropColumn(
name: "VendorCreditId1",
table: "VendorCreditApplications");
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 1,
column: "CreatedAt",
value: new DateTime(2026, 5, 10, 15, 25, 26, 831, DateTimeKind.Utc).AddTicks(199));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 2,
column: "CreatedAt",
value: new DateTime(2026, 5, 10, 15, 25, 26, 831, DateTimeKind.Utc).AddTicks(205));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 3,
column: "CreatedAt",
value: new DateTime(2026, 5, 10, 15, 25, 26, 831, DateTimeKind.Utc).AddTicks(206));
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<int>(
name: "VendorCreditId1",
table: "VendorCreditApplications",
type: "int",
nullable: true);
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 1,
column: "CreatedAt",
value: new DateTime(2026, 5, 10, 15, 1, 54, 769, DateTimeKind.Utc).AddTicks(6262));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 2,
column: "CreatedAt",
value: new DateTime(2026, 5, 10, 15, 1, 54, 769, DateTimeKind.Utc).AddTicks(6270));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 3,
column: "CreatedAt",
value: new DateTime(2026, 5, 10, 15, 1, 54, 769, DateTimeKind.Utc).AddTicks(6271));
migrationBuilder.CreateIndex(
name: "IX_VendorCreditApplications_VendorCreditId1",
table: "VendorCreditApplications",
column: "VendorCreditId1");
migrationBuilder.AddForeignKey(
name: "FK_VendorCreditApplications_VendorCredits_VendorCreditId1",
table: "VendorCreditApplications",
column: "VendorCreditId1",
principalTable: "VendorCredits",
principalColumn: "Id");
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,199 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace PowderCoating.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class AddFixedAssetsLockAnd1099 : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "Is1099Vendor",
table: "Vendors",
type: "bit",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<DateTime>(
name: "BookLockedThrough",
table: "Companies",
type: "datetime2",
nullable: true);
migrationBuilder.CreateTable(
name: "FixedAssets",
columns: table => new
{
Id = table.Column<int>(type: "int", nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
Name = table.Column<string>(type: "nvarchar(max)", nullable: false),
Description = table.Column<string>(type: "nvarchar(max)", nullable: true),
PurchaseDate = table.Column<DateTime>(type: "datetime2", nullable: false),
PurchaseCost = table.Column<decimal>(type: "decimal(18,2)", nullable: false),
SalvageValue = table.Column<decimal>(type: "decimal(18,2)", nullable: false),
UsefulLifeMonths = table.Column<int>(type: "int", nullable: false),
AccumulatedDepreciation = table.Column<decimal>(type: "decimal(18,2)", nullable: false),
IsDisposed = table.Column<bool>(type: "bit", nullable: false),
DisposalDate = table.Column<DateTime>(type: "datetime2", nullable: true),
AssetAccountId = table.Column<int>(type: "int", nullable: true),
DepreciationExpenseAccountId = table.Column<int>(type: "int", nullable: true),
AccumDepreciationAccountId = table.Column<int>(type: "int", nullable: true),
CompanyId = table.Column<int>(type: "int", nullable: false),
CreatedAt = table.Column<DateTime>(type: "datetime2", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
CreatedBy = table.Column<string>(type: "nvarchar(max)", nullable: true),
UpdatedBy = table.Column<string>(type: "nvarchar(max)", nullable: true),
IsDeleted = table.Column<bool>(type: "bit", nullable: false),
DeletedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
DeletedBy = table.Column<string>(type: "nvarchar(max)", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_FixedAssets", x => x.Id);
table.ForeignKey(
name: "FK_FixedAssets_Accounts_AccumDepreciationAccountId",
column: x => x.AccumDepreciationAccountId,
principalTable: "Accounts",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_FixedAssets_Accounts_AssetAccountId",
column: x => x.AssetAccountId,
principalTable: "Accounts",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_FixedAssets_Accounts_DepreciationExpenseAccountId",
column: x => x.DepreciationExpenseAccountId,
principalTable: "Accounts",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
});
migrationBuilder.CreateTable(
name: "FixedAssetDepreciationEntries",
columns: table => new
{
Id = table.Column<int>(type: "int", nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
FixedAssetId = table.Column<int>(type: "int", nullable: false),
PeriodYear = table.Column<int>(type: "int", nullable: false),
PeriodMonth = table.Column<int>(type: "int", nullable: false),
Amount = table.Column<decimal>(type: "decimal(18,2)", nullable: false),
JournalEntryId = table.Column<int>(type: "int", nullable: true),
CompanyId = table.Column<int>(type: "int", nullable: false),
CreatedAt = table.Column<DateTime>(type: "datetime2", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
CreatedBy = table.Column<string>(type: "nvarchar(max)", nullable: true),
UpdatedBy = table.Column<string>(type: "nvarchar(max)", nullable: true),
IsDeleted = table.Column<bool>(type: "bit", nullable: false),
DeletedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
DeletedBy = table.Column<string>(type: "nvarchar(max)", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_FixedAssetDepreciationEntries", x => x.Id);
table.ForeignKey(
name: "FK_FixedAssetDepreciationEntries_FixedAssets_FixedAssetId",
column: x => x.FixedAssetId,
principalTable: "FixedAssets",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_FixedAssetDepreciationEntries_JournalEntries_JournalEntryId",
column: x => x.JournalEntryId,
principalTable: "JournalEntries",
principalColumn: "Id");
});
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 1,
column: "CreatedAt",
value: new DateTime(2026, 5, 10, 16, 0, 49, 626, DateTimeKind.Utc).AddTicks(4004));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 2,
column: "CreatedAt",
value: new DateTime(2026, 5, 10, 16, 0, 49, 626, DateTimeKind.Utc).AddTicks(4009));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 3,
column: "CreatedAt",
value: new DateTime(2026, 5, 10, 16, 0, 49, 626, DateTimeKind.Utc).AddTicks(4011));
migrationBuilder.CreateIndex(
name: "IX_FixedAssetDepreciationEntries_FixedAssetId",
table: "FixedAssetDepreciationEntries",
column: "FixedAssetId");
migrationBuilder.CreateIndex(
name: "IX_FixedAssetDepreciationEntries_JournalEntryId",
table: "FixedAssetDepreciationEntries",
column: "JournalEntryId");
migrationBuilder.CreateIndex(
name: "IX_FixedAssets_AccumDepreciationAccountId",
table: "FixedAssets",
column: "AccumDepreciationAccountId");
migrationBuilder.CreateIndex(
name: "IX_FixedAssets_AssetAccountId",
table: "FixedAssets",
column: "AssetAccountId");
migrationBuilder.CreateIndex(
name: "IX_FixedAssets_DepreciationExpenseAccountId",
table: "FixedAssets",
column: "DepreciationExpenseAccountId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "FixedAssetDepreciationEntries");
migrationBuilder.DropTable(
name: "FixedAssets");
migrationBuilder.DropColumn(
name: "Is1099Vendor",
table: "Vendors");
migrationBuilder.DropColumn(
name: "BookLockedThrough",
table: "Companies");
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 1,
column: "CreatedAt",
value: new DateTime(2026, 5, 10, 15, 25, 26, 831, DateTimeKind.Utc).AddTicks(199));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 2,
column: "CreatedAt",
value: new DateTime(2026, 5, 10, 15, 25, 26, 831, DateTimeKind.Utc).AddTicks(205));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 3,
column: "CreatedAt",
value: new DateTime(2026, 5, 10, 15, 25, 26, 831, DateTimeKind.Utc).AddTicks(206));
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,185 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace PowderCoating.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class AddBudgetsAndYearEndClose : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "Budgets",
columns: table => new
{
Id = table.Column<int>(type: "int", nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
Name = table.Column<string>(type: "nvarchar(max)", nullable: false),
FiscalYear = table.Column<int>(type: "int", nullable: false),
Notes = table.Column<string>(type: "nvarchar(max)", nullable: true),
IsDefault = table.Column<bool>(type: "bit", nullable: false),
CompanyId = table.Column<int>(type: "int", nullable: false),
CreatedAt = table.Column<DateTime>(type: "datetime2", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
CreatedBy = table.Column<string>(type: "nvarchar(max)", nullable: true),
UpdatedBy = table.Column<string>(type: "nvarchar(max)", nullable: true),
IsDeleted = table.Column<bool>(type: "bit", nullable: false),
DeletedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
DeletedBy = table.Column<string>(type: "nvarchar(max)", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_Budgets", x => x.Id);
});
migrationBuilder.CreateTable(
name: "YearEndCloses",
columns: table => new
{
Id = table.Column<int>(type: "int", nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
ClosedYear = table.Column<int>(type: "int", nullable: false),
ClosedAt = table.Column<DateTime>(type: "datetime2", nullable: false),
ClosedBy = table.Column<string>(type: "nvarchar(max)", nullable: true),
JournalEntryId = table.Column<int>(type: "int", nullable: false),
CompanyId = table.Column<int>(type: "int", nullable: false),
CreatedAt = table.Column<DateTime>(type: "datetime2", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
CreatedBy = table.Column<string>(type: "nvarchar(max)", nullable: true),
UpdatedBy = table.Column<string>(type: "nvarchar(max)", nullable: true),
IsDeleted = table.Column<bool>(type: "bit", nullable: false),
DeletedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
DeletedBy = table.Column<string>(type: "nvarchar(max)", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_YearEndCloses", x => x.Id);
table.ForeignKey(
name: "FK_YearEndCloses_JournalEntries_JournalEntryId",
column: x => x.JournalEntryId,
principalTable: "JournalEntries",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
});
migrationBuilder.CreateTable(
name: "BudgetLines",
columns: table => new
{
Id = table.Column<int>(type: "int", nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
BudgetId = table.Column<int>(type: "int", nullable: false),
AccountId = table.Column<int>(type: "int", nullable: false),
Jan = table.Column<decimal>(type: "decimal(18,2)", nullable: false),
Feb = table.Column<decimal>(type: "decimal(18,2)", nullable: false),
Mar = table.Column<decimal>(type: "decimal(18,2)", nullable: false),
Apr = table.Column<decimal>(type: "decimal(18,2)", nullable: false),
May = table.Column<decimal>(type: "decimal(18,2)", nullable: false),
Jun = table.Column<decimal>(type: "decimal(18,2)", nullable: false),
Jul = table.Column<decimal>(type: "decimal(18,2)", nullable: false),
Aug = table.Column<decimal>(type: "decimal(18,2)", nullable: false),
Sep = table.Column<decimal>(type: "decimal(18,2)", nullable: false),
Oct = table.Column<decimal>(type: "decimal(18,2)", nullable: false),
Nov = table.Column<decimal>(type: "decimal(18,2)", nullable: false),
Dec = table.Column<decimal>(type: "decimal(18,2)", nullable: false),
CompanyId = table.Column<int>(type: "int", nullable: false),
CreatedAt = table.Column<DateTime>(type: "datetime2", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
CreatedBy = table.Column<string>(type: "nvarchar(max)", nullable: true),
UpdatedBy = table.Column<string>(type: "nvarchar(max)", nullable: true),
IsDeleted = table.Column<bool>(type: "bit", nullable: false),
DeletedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
DeletedBy = table.Column<string>(type: "nvarchar(max)", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_BudgetLines", x => x.Id);
table.ForeignKey(
name: "FK_BudgetLines_Accounts_AccountId",
column: x => x.AccountId,
principalTable: "Accounts",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_BudgetLines_Budgets_BudgetId",
column: x => x.BudgetId,
principalTable: "Budgets",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 1,
column: "CreatedAt",
value: new DateTime(2026, 5, 10, 16, 55, 8, 322, DateTimeKind.Utc).AddTicks(966));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 2,
column: "CreatedAt",
value: new DateTime(2026, 5, 10, 16, 55, 8, 322, DateTimeKind.Utc).AddTicks(974));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 3,
column: "CreatedAt",
value: new DateTime(2026, 5, 10, 16, 55, 8, 322, DateTimeKind.Utc).AddTicks(976));
migrationBuilder.CreateIndex(
name: "IX_BudgetLines_AccountId",
table: "BudgetLines",
column: "AccountId");
migrationBuilder.CreateIndex(
name: "IX_BudgetLines_BudgetId",
table: "BudgetLines",
column: "BudgetId");
migrationBuilder.CreateIndex(
name: "IX_YearEndCloses_JournalEntryId",
table: "YearEndCloses",
column: "JournalEntryId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "BudgetLines");
migrationBuilder.DropTable(
name: "YearEndCloses");
migrationBuilder.DropTable(
name: "Budgets");
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 1,
column: "CreatedAt",
value: new DateTime(2026, 5, 10, 16, 0, 49, 626, DateTimeKind.Utc).AddTicks(4004));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 2,
column: "CreatedAt",
value: new DateTime(2026, 5, 10, 16, 0, 49, 626, DateTimeKind.Utc).AddTicks(4009));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 3,
column: "CreatedAt",
value: new DateTime(2026, 5, 10, 16, 0, 49, 626, DateTimeKind.Utc).AddTicks(4011));
}
}
}
@@ -0,0 +1,90 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace PowderCoating.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class AddAccountantRolePermissions : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "CanManageAccounting",
table: "AspNetUsers",
type: "bit",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<bool>(
name: "CanManageBills",
table: "AspNetUsers",
type: "bit",
nullable: false,
defaultValue: false);
// Grant both new permissions to all existing CompanyAdmin users so they don't lose access
migrationBuilder.Sql(@"
UPDATE AspNetUsers
SET CanManageBills = 1, CanManageAccounting = 1
WHERE CompanyRole = 'CompanyAdmin'
");
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 1,
column: "CreatedAt",
value: new DateTime(2026, 5, 10, 23, 40, 54, 100, DateTimeKind.Utc).AddTicks(8999));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 2,
column: "CreatedAt",
value: new DateTime(2026, 5, 10, 23, 40, 54, 100, DateTimeKind.Utc).AddTicks(9005));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 3,
column: "CreatedAt",
value: new DateTime(2026, 5, 10, 23, 40, 54, 100, DateTimeKind.Utc).AddTicks(9007));
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "CanManageAccounting",
table: "AspNetUsers");
migrationBuilder.DropColumn(
name: "CanManageBills",
table: "AspNetUsers");
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 1,
column: "CreatedAt",
value: new DateTime(2026, 5, 10, 16, 55, 8, 322, DateTimeKind.Utc).AddTicks(966));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 2,
column: "CreatedAt",
value: new DateTime(2026, 5, 10, 16, 55, 8, 322, DateTimeKind.Utc).AddTicks(974));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 3,
column: "CreatedAt",
value: new DateTime(2026, 5, 10, 16, 55, 8, 322, DateTimeKind.Utc).AddTicks(976));
}
}
}
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 AddJobOvenBatchCost : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<decimal>(
name: "OvenBatchCost",
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, 11, 14, 23, 23, 221, DateTimeKind.Utc).AddTicks(5837));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 2,
column: "CreatedAt",
value: new DateTime(2026, 5, 11, 14, 23, 23, 221, DateTimeKind.Utc).AddTicks(5846));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 3,
column: "CreatedAt",
value: new DateTime(2026, 5, 11, 14, 23, 23, 221, DateTimeKind.Utc).AddTicks(5847));
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "OvenBatchCost",
table: "Jobs");
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 1,
column: "CreatedAt",
value: new DateTime(2026, 5, 10, 23, 40, 54, 100, DateTimeKind.Utc).AddTicks(8999));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 2,
column: "CreatedAt",
value: new DateTime(2026, 5, 10, 23, 40, 54, 100, DateTimeKind.Utc).AddTicks(9005));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 3,
column: "CreatedAt",
value: new DateTime(2026, 5, 10, 23, 40, 54, 100, DateTimeKind.Utc).AddTicks(9007));
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,88 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace PowderCoating.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class AddMissingPlatformSettings : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
// Conditional inserts — safe to run against a DB that already has some of these keys set manually.
migrationBuilder.Sql(@"
IF NOT EXISTS (SELECT 1 FROM PlatformSettings WHERE [Key] = 'SmsEnabled')
INSERT INTO PlatformSettings ([Key],[Value],[Label],[Description],[GroupName])
VALUES ('SmsEnabled','false','SMS Enabled','Platform-level switch for outbound SMS. When off, no SMS messages are sent regardless of company settings.','Notifications');
IF NOT EXISTS (SELECT 1 FROM PlatformSettings WHERE [Key] = 'TrialsEnabled')
INSERT INTO PlatformSettings ([Key],[Value],[Label],[Description],[GroupName])
VALUES ('TrialsEnabled','true','Trials Enabled','Allow new companies to register with a free trial period. When off, registration requires a paid plan immediately.','Subscriptions');
IF NOT EXISTS (SELECT 1 FROM PlatformSettings WHERE [Key] = 'GracePeriodDays')
INSERT INTO PlatformSettings ([Key],[Value],[Label],[Description],[GroupName])
VALUES ('GracePeriodDays','14','Grace Period (days)','Days after subscription expiry before access is fully cut off. Gives companies time to renew without an abrupt lockout.','Subscriptions');
IF NOT EXISTS (SELECT 1 FROM PlatformSettings WHERE [Key] = 'GracePeriodAppliesToTrials')
INSERT INTO PlatformSettings ([Key],[Value],[Label],[Description],[GroupName])
VALUES ('GracePeriodAppliesToTrials','false','Grace Period Applies to Trials','When enabled, trial companies also receive the grace period after expiry rather than being cut off immediately.','Subscriptions');
IF NOT EXISTS (SELECT 1 FROM PlatformSettings WHERE [Key] = 'MaxTenants')
INSERT INTO PlatformSettings ([Key],[Value],[Label],[Description],[GroupName])
VALUES ('MaxTenants','-1','Max Tenants','Maximum number of active tenant companies allowed on the platform. Set to -1 for no limit.','Subscriptions');
IF NOT EXISTS (SELECT 1 FROM PlatformSettings WHERE [Key] = 'AiCatalogPriceCheckEnabled')
INSERT INTO PlatformSettings ([Key],[Value],[Label],[Description],[GroupName])
VALUES ('AiCatalogPriceCheckEnabled','true','AI Catalog Price Check','Platform-level switch for the AI catalog price review feature. When off, the feature is disabled for all companies regardless of their settings.','AI');
");
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 1,
column: "CreatedAt",
value: new DateTime(2026, 5, 13, 1, 34, 45, 450, DateTimeKind.Utc).AddTicks(8377));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 2,
column: "CreatedAt",
value: new DateTime(2026, 5, 13, 1, 34, 45, 450, DateTimeKind.Utc).AddTicks(8383));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 3,
column: "CreatedAt",
value: new DateTime(2026, 5, 13, 1, 34, 45, 450, DateTimeKind.Utc).AddTicks(8385));
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 1,
column: "CreatedAt",
value: new DateTime(2026, 5, 11, 14, 23, 23, 221, DateTimeKind.Utc).AddTicks(5837));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 2,
column: "CreatedAt",
value: new DateTime(2026, 5, 11, 14, 23, 23, 221, DateTimeKind.Utc).AddTicks(5846));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 3,
column: "CreatedAt",
value: new DateTime(2026, 5, 11, 14, 23, 23, 221, DateTimeKind.Utc).AddTicks(5847));
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,95 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace PowderCoating.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class SeedSalesDiscountsAccount : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
// Insert the 4950 Sales Discounts contra-revenue account for every company that does
// not already have it. The account is credit-normal (AccountType=4 Revenue,
// AccountSubType=32 OtherIncome) and is debited when invoice discounts are applied so
// the GL balances (DR Sales Discounts / gap between CR Revenue and DR AR).
// Idempotent: the WHERE NOT EXISTS guard means re-running the migration is safe.
migrationBuilder.Sql(@"
INSERT INTO Accounts
(AccountNumber, Name, AccountType, AccountSubType,
IsSystem, IsActive, Description,
CompanyId, CreatedAt, IsDeleted,
CurrentBalance, OpeningBalance)
SELECT
'4950',
'Sales Discounts',
4, -- AccountType.Revenue
32, -- AccountSubType.OtherIncome
1, -- IsSystem = true
1, -- IsActive = true
'Contra-revenue for invoice discounts granted to customers',
c.Id,
GETUTCDATE(),
0, -- IsDeleted = false
0, -- CurrentBalance
0 -- OpeningBalance
FROM Companies c
WHERE c.IsDeleted = 0
AND NOT EXISTS (
SELECT 1 FROM Accounts a
WHERE a.CompanyId = c.Id
AND a.AccountNumber = '4950'
AND a.IsDeleted = 0
);
");
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 1,
column: "CreatedAt",
value: new DateTime(2026, 5, 13, 13, 39, 22, 61, DateTimeKind.Utc).AddTicks(8475));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 2,
column: "CreatedAt",
value: new DateTime(2026, 5, 13, 13, 39, 22, 61, DateTimeKind.Utc).AddTicks(8484));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 3,
column: "CreatedAt",
value: new DateTime(2026, 5, 13, 13, 39, 22, 61, DateTimeKind.Utc).AddTicks(8486));
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 1,
column: "CreatedAt",
value: new DateTime(2026, 5, 13, 1, 34, 45, 450, DateTimeKind.Utc).AddTicks(8377));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 2,
column: "CreatedAt",
value: new DateTime(2026, 5, 13, 1, 34, 45, 450, DateTimeKind.Utc).AddTicks(8383));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 3,
column: "CreatedAt",
value: new DateTime(2026, 5, 13, 1, 34, 45, 450, DateTimeKind.Utc).AddTicks(8385));
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,113 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace PowderCoating.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class AccountingGapsPhase2 : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<DateTime>(
name: "PostedDate",
table: "VendorCredits",
type: "datetime2",
nullable: true);
migrationBuilder.AddColumn<int>(
name: "DepositAccountId",
table: "Refunds",
type: "int",
nullable: true);
// Seed the Gift Certificate Liability account (2500) for every company that doesn't
// already have it. Credit-normal OtherCurrentLiability account; credited when a GC is
// issued and debited when redeemed or voided. Idempotent guard prevents double-seeding.
migrationBuilder.Sql(@"
INSERT INTO Accounts
(AccountNumber, Name, AccountType, AccountSubType,
IsSystem, IsActive, Description,
CompanyId, CreatedAt, IsDeleted,
CurrentBalance, OpeningBalance)
SELECT
'2500',
'Gift Certificate Liability',
2, -- AccountType.Liability
12, -- AccountSubType.OtherCurrentLiability
1, -- IsSystem = true
1, -- IsActive = true
'Outstanding gift certificate obligations owed to certificate holders',
c.Id,
GETUTCDATE(),
0, -- IsDeleted = false
0, -- CurrentBalance
0 -- OpeningBalance
FROM Companies c
WHERE c.IsDeleted = 0
AND NOT EXISTS (
SELECT 1 FROM Accounts a
WHERE a.CompanyId = c.Id
AND a.AccountNumber = '2500'
AND a.IsDeleted = 0
);
");
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 1,
column: "CreatedAt",
value: new DateTime(2026, 5, 13, 14, 24, 44, 715, DateTimeKind.Utc).AddTicks(9166));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 2,
column: "CreatedAt",
value: new DateTime(2026, 5, 13, 14, 24, 44, 715, DateTimeKind.Utc).AddTicks(9172));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 3,
column: "CreatedAt",
value: new DateTime(2026, 5, 13, 14, 24, 44, 715, DateTimeKind.Utc).AddTicks(9174));
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "PostedDate",
table: "VendorCredits");
migrationBuilder.DropColumn(
name: "DepositAccountId",
table: "Refunds");
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 1,
column: "CreatedAt",
value: new DateTime(2026, 5, 13, 13, 39, 22, 61, DateTimeKind.Utc).AddTicks(8475));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 2,
column: "CreatedAt",
value: new DateTime(2026, 5, 13, 13, 39, 22, 61, DateTimeKind.Utc).AddTicks(8484));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 3,
column: "CreatedAt",
value: new DateTime(2026, 5, 13, 13, 39, 22, 61, DateTimeKind.Utc).AddTicks(8486));
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,103 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace PowderCoating.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class AccountingDepositsGL : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<int>(
name: "DepositAccountId",
table: "Deposits",
type: "int",
nullable: true);
// Seed account 2300 "Customer Deposits" (Liability / OtherCurrentLiability) for every
// company that doesn't already have it. Credited when a deposit is taken; debited when
// the deposit is applied to an invoice. Idempotent guard prevents double-seeding.
migrationBuilder.Sql(@"
INSERT INTO Accounts
(AccountNumber, Name, AccountType, AccountSubType,
IsSystem, IsActive, Description,
CompanyId, CreatedAt, IsDeleted,
CurrentBalance, OpeningBalance)
SELECT
'2300',
'Customer Deposits',
2, -- AccountType.Liability
12, -- AccountSubType.OtherCurrentLiability
1, -- IsSystem = true
1, -- IsActive = true
'Deposits received from customers before an invoice is created; cleared when deposit is applied to invoice',
c.Id,
GETUTCDATE(),
0, -- IsDeleted = false
0, -- CurrentBalance
0 -- OpeningBalance
FROM Companies c
WHERE c.IsDeleted = 0
AND NOT EXISTS (
SELECT 1 FROM Accounts a
WHERE a.CompanyId = c.Id
AND a.AccountNumber = '2300'
AND a.IsDeleted = 0
);
");
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 1,
column: "CreatedAt",
value: new DateTime(2026, 5, 13, 14, 57, 30, 15, DateTimeKind.Utc).AddTicks(5641));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 2,
column: "CreatedAt",
value: new DateTime(2026, 5, 13, 14, 57, 30, 15, DateTimeKind.Utc).AddTicks(5655));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 3,
column: "CreatedAt",
value: new DateTime(2026, 5, 13, 14, 57, 30, 15, DateTimeKind.Utc).AddTicks(5656));
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "DepositAccountId",
table: "Deposits");
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 1,
column: "CreatedAt",
value: new DateTime(2026, 5, 13, 14, 24, 44, 715, DateTimeKind.Utc).AddTicks(9166));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 2,
column: "CreatedAt",
value: new DateTime(2026, 5, 13, 14, 24, 44, 715, DateTimeKind.Utc).AddTicks(9172));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 3,
column: "CreatedAt",
value: new DateTime(2026, 5, 13, 14, 24, 44, 715, DateTimeKind.Utc).AddTicks(9174));
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,142 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace PowderCoating.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class AddKioskIntakeSession : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "KioskActivationToken",
table: "Companies",
type: "nvarchar(max)",
nullable: true);
migrationBuilder.CreateTable(
name: "KioskSessions",
columns: table => new
{
Id = table.Column<int>(type: "int", nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
SessionToken = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
SessionType = table.Column<int>(type: "int", nullable: false),
Status = table.Column<int>(type: "int", nullable: false),
CustomerFirstName = table.Column<string>(type: "nvarchar(max)", nullable: false),
CustomerLastName = table.Column<string>(type: "nvarchar(max)", nullable: false),
CustomerPhone = table.Column<string>(type: "nvarchar(max)", nullable: false),
CustomerEmail = table.Column<string>(type: "nvarchar(max)", nullable: false),
IsReturningCustomer = table.Column<bool>(type: "bit", nullable: false),
JobDescription = table.Column<string>(type: "nvarchar(max)", nullable: false),
HowDidYouHearAboutUs = table.Column<string>(type: "nvarchar(max)", nullable: true),
AgreedToTerms = table.Column<bool>(type: "bit", nullable: false),
AgreedToTermsAt = table.Column<DateTime>(type: "datetime2", nullable: true),
SmsOptIn = table.Column<bool>(type: "bit", nullable: false),
SignatureDataBase64 = table.Column<string>(type: "nvarchar(max)", nullable: true),
LinkedCustomerId = table.Column<int>(type: "int", nullable: true),
LinkedJobId = table.Column<int>(type: "int", nullable: true),
SubmittedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
ExpiresAt = table.Column<DateTime>(type: "datetime2", nullable: false),
RemoteLinkEmail = table.Column<string>(type: "nvarchar(max)", nullable: true),
RemoteLinkSentAt = table.Column<DateTime>(type: "datetime2", nullable: true),
CompanyId = table.Column<int>(type: "int", nullable: false),
CreatedAt = table.Column<DateTime>(type: "datetime2", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
CreatedBy = table.Column<string>(type: "nvarchar(max)", nullable: true),
UpdatedBy = table.Column<string>(type: "nvarchar(max)", nullable: true),
IsDeleted = table.Column<bool>(type: "bit", nullable: false),
DeletedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
DeletedBy = table.Column<string>(type: "nvarchar(max)", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_KioskSessions", x => x.Id);
table.ForeignKey(
name: "FK_KioskSessions_Customers_LinkedCustomerId",
column: x => x.LinkedCustomerId,
principalTable: "Customers",
principalColumn: "Id",
onDelete: ReferentialAction.SetNull);
table.ForeignKey(
name: "FK_KioskSessions_Jobs_LinkedJobId",
column: x => x.LinkedJobId,
principalTable: "Jobs",
principalColumn: "Id",
onDelete: ReferentialAction.SetNull);
});
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 1,
column: "CreatedAt",
value: new DateTime(2026, 5, 13, 18, 40, 15, 633, DateTimeKind.Utc).AddTicks(8207));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 2,
column: "CreatedAt",
value: new DateTime(2026, 5, 13, 18, 40, 15, 633, DateTimeKind.Utc).AddTicks(8213));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 3,
column: "CreatedAt",
value: new DateTime(2026, 5, 13, 18, 40, 15, 633, DateTimeKind.Utc).AddTicks(8215));
migrationBuilder.CreateIndex(
name: "IX_KioskSessions_LinkedCustomerId",
table: "KioskSessions",
column: "LinkedCustomerId");
migrationBuilder.CreateIndex(
name: "IX_KioskSessions_LinkedJobId",
table: "KioskSessions",
column: "LinkedJobId");
migrationBuilder.CreateIndex(
name: "IX_KioskSessions_SessionToken",
table: "KioskSessions",
column: "SessionToken",
unique: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "KioskSessions");
migrationBuilder.DropColumn(
name: "KioskActivationToken",
table: "Companies");
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 1,
column: "CreatedAt",
value: new DateTime(2026, 5, 13, 14, 57, 30, 15, DateTimeKind.Utc).AddTicks(5641));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 2,
column: "CreatedAt",
value: new DateTime(2026, 5, 13, 14, 57, 30, 15, DateTimeKind.Utc).AddTicks(5655));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 3,
column: "CreatedAt",
value: new DateTime(2026, 5, 13, 14, 57, 30, 15, DateTimeKind.Utc).AddTicks(5656));
}
}
}
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 AddInvoicePublicViewToken : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "PublicViewToken",
table: "Invoices",
type: "nvarchar(max)",
nullable: true);
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 1,
column: "CreatedAt",
value: new DateTime(2026, 5, 13, 19, 28, 44, 26, DateTimeKind.Utc).AddTicks(4259));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 2,
column: "CreatedAt",
value: new DateTime(2026, 5, 13, 19, 28, 44, 26, DateTimeKind.Utc).AddTicks(4264));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 3,
column: "CreatedAt",
value: new DateTime(2026, 5, 13, 19, 28, 44, 26, DateTimeKind.Utc).AddTicks(4266));
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "PublicViewToken",
table: "Invoices");
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 1,
column: "CreatedAt",
value: new DateTime(2026, 5, 13, 18, 40, 15, 633, DateTimeKind.Utc).AddTicks(8207));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 2,
column: "CreatedAt",
value: new DateTime(2026, 5, 13, 18, 40, 15, 633, DateTimeKind.Utc).AddTicks(8213));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 3,
column: "CreatedAt",
value: new DateTime(2026, 5, 13, 18, 40, 15, 633, DateTimeKind.Utc).AddTicks(8215));
}
}
}
@@ -0,0 +1,82 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace PowderCoating.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class AddKioskIntakeOutputSetting : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<int>(
name: "LinkedQuoteId",
table: "KioskSessions",
type: "int",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "KioskIntakeOutput",
table: "CompanyPreferences",
type: "nvarchar(max)",
nullable: false,
defaultValue: "");
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 1,
column: "CreatedAt",
value: new DateTime(2026, 5, 14, 2, 27, 27, 993, DateTimeKind.Utc).AddTicks(2349));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 2,
column: "CreatedAt",
value: new DateTime(2026, 5, 14, 2, 27, 27, 993, DateTimeKind.Utc).AddTicks(2366));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 3,
column: "CreatedAt",
value: new DateTime(2026, 5, 14, 2, 27, 27, 993, DateTimeKind.Utc).AddTicks(2367));
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "LinkedQuoteId",
table: "KioskSessions");
migrationBuilder.DropColumn(
name: "KioskIntakeOutput",
table: "CompanyPreferences");
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 1,
column: "CreatedAt",
value: new DateTime(2026, 5, 13, 19, 28, 44, 26, DateTimeKind.Utc).AddTicks(4259));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 2,
column: "CreatedAt",
value: new DateTime(2026, 5, 13, 19, 28, 44, 26, DateTimeKind.Utc).AddTicks(4264));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 3,
column: "CreatedAt",
value: new DateTime(2026, 5, 13, 19, 28, 44, 26, DateTimeKind.Utc).AddTicks(4266));
}
}
}
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 AddJobOvenBatchFields : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<int>(
name: "OvenBatches",
table: "Jobs",
type: "int",
nullable: false,
defaultValue: 0);
migrationBuilder.AddColumn<int>(
name: "OvenCycleMinutes",
table: "Jobs",
type: "int",
nullable: true);
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 1,
column: "CreatedAt",
value: new DateTime(2026, 5, 14, 20, 5, 39, 939, DateTimeKind.Utc).AddTicks(6420));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 2,
column: "CreatedAt",
value: new DateTime(2026, 5, 14, 20, 5, 39, 939, DateTimeKind.Utc).AddTicks(6425));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 3,
column: "CreatedAt",
value: new DateTime(2026, 5, 14, 20, 5, 39, 939, DateTimeKind.Utc).AddTicks(6426));
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "OvenBatches",
table: "Jobs");
migrationBuilder.DropColumn(
name: "OvenCycleMinutes",
table: "Jobs");
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 1,
column: "CreatedAt",
value: new DateTime(2026, 5, 14, 2, 27, 27, 993, DateTimeKind.Utc).AddTicks(2349));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 2,
column: "CreatedAt",
value: new DateTime(2026, 5, 14, 2, 27, 27, 993, DateTimeKind.Utc).AddTicks(2366));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 3,
column: "CreatedAt",
value: new DateTime(2026, 5, 14, 2, 27, 27, 993, DateTimeKind.Utc).AddTicks(2367));
}
}
}
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 AddJobItemIsAiItem : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "IsAiItem",
table: "JobItems",
type: "bit",
nullable: false,
defaultValue: false);
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 1,
column: "CreatedAt",
value: new DateTime(2026, 5, 14, 20, 43, 43, 116, DateTimeKind.Utc).AddTicks(7475));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 2,
column: "CreatedAt",
value: new DateTime(2026, 5, 14, 20, 43, 43, 116, DateTimeKind.Utc).AddTicks(7481));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 3,
column: "CreatedAt",
value: new DateTime(2026, 5, 14, 20, 43, 43, 116, DateTimeKind.Utc).AddTicks(7482));
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "IsAiItem",
table: "JobItems");
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 1,
column: "CreatedAt",
value: new DateTime(2026, 5, 14, 20, 5, 39, 939, DateTimeKind.Utc).AddTicks(6420));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 2,
column: "CreatedAt",
value: new DateTime(2026, 5, 14, 20, 5, 39, 939, DateTimeKind.Utc).AddTicks(6425));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 3,
column: "CreatedAt",
value: new DateTime(2026, 5, 14, 20, 5, 39, 939, DateTimeKind.Utc).AddTicks(6426));
}
}
}
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 AddGiftCertificateBatchId : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<Guid>(
name: "BatchId",
table: "GiftCertificates",
type: "uniqueidentifier",
nullable: true);
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 1,
column: "CreatedAt",
value: new DateTime(2026, 5, 15, 0, 30, 26, 297, DateTimeKind.Utc).AddTicks(7656));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 2,
column: "CreatedAt",
value: new DateTime(2026, 5, 15, 0, 30, 26, 297, DateTimeKind.Utc).AddTicks(7662));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 3,
column: "CreatedAt",
value: new DateTime(2026, 5, 15, 0, 30, 26, 297, DateTimeKind.Utc).AddTicks(7664));
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "BatchId",
table: "GiftCertificates");
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 1,
column: "CreatedAt",
value: new DateTime(2026, 5, 14, 20, 43, 43, 116, DateTimeKind.Utc).AddTicks(7475));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 2,
column: "CreatedAt",
value: new DateTime(2026, 5, 14, 20, 43, 43, 116, DateTimeKind.Utc).AddTicks(7481));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 3,
column: "CreatedAt",
value: new DateTime(2026, 5, 14, 20, 43, 43, 116, DateTimeKind.Utc).AddTicks(7482));
}
}
}
@@ -463,6 +463,12 @@ namespace PowderCoating.Infrastructure.Migrations
b.Property<bool>("CanCreateQuotes")
.HasColumnType("bit");
b.Property<bool>("CanManageAccounting")
.HasColumnType("bit");
b.Property<bool>("CanManageBills")
.HasColumnType("bit");
b.Property<bool>("CanManageCalendar")
.HasColumnType("bit");
@@ -1269,6 +1275,139 @@ namespace PowderCoating.Infrastructure.Migrations
b.ToTable("BillPayments");
});
modelBuilder.Entity("PowderCoating.Core.Entities.Budget", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<int>("CompanyId")
.HasColumnType("int");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime2");
b.Property<string>("CreatedBy")
.HasColumnType("nvarchar(max)");
b.Property<DateTime?>("DeletedAt")
.HasColumnType("datetime2");
b.Property<string>("DeletedBy")
.HasColumnType("nvarchar(max)");
b.Property<int>("FiscalYear")
.HasColumnType("int");
b.Property<bool>("IsDefault")
.HasColumnType("bit");
b.Property<bool>("IsDeleted")
.HasColumnType("bit");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<string>("Notes")
.HasColumnType("nvarchar(max)");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("datetime2");
b.Property<string>("UpdatedBy")
.HasColumnType("nvarchar(max)");
b.HasKey("Id");
b.ToTable("Budgets");
});
modelBuilder.Entity("PowderCoating.Core.Entities.BudgetLine", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<int>("AccountId")
.HasColumnType("int");
b.Property<decimal>("Apr")
.HasColumnType("decimal(18,2)");
b.Property<decimal>("Aug")
.HasColumnType("decimal(18,2)");
b.Property<int>("BudgetId")
.HasColumnType("int");
b.Property<int>("CompanyId")
.HasColumnType("int");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime2");
b.Property<string>("CreatedBy")
.HasColumnType("nvarchar(max)");
b.Property<decimal>("Dec")
.HasColumnType("decimal(18,2)");
b.Property<DateTime?>("DeletedAt")
.HasColumnType("datetime2");
b.Property<string>("DeletedBy")
.HasColumnType("nvarchar(max)");
b.Property<decimal>("Feb")
.HasColumnType("decimal(18,2)");
b.Property<bool>("IsDeleted")
.HasColumnType("bit");
b.Property<decimal>("Jan")
.HasColumnType("decimal(18,2)");
b.Property<decimal>("Jul")
.HasColumnType("decimal(18,2)");
b.Property<decimal>("Jun")
.HasColumnType("decimal(18,2)");
b.Property<decimal>("Mar")
.HasColumnType("decimal(18,2)");
b.Property<decimal>("May")
.HasColumnType("decimal(18,2)");
b.Property<decimal>("Nov")
.HasColumnType("decimal(18,2)");
b.Property<decimal>("Oct")
.HasColumnType("decimal(18,2)");
b.Property<decimal>("Sep")
.HasColumnType("decimal(18,2)");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("datetime2");
b.Property<string>("UpdatedBy")
.HasColumnType("nvarchar(max)");
b.HasKey("Id");
b.HasIndex("AccountId");
b.HasIndex("BudgetId");
b.ToTable("BudgetLines");
});
modelBuilder.Entity("PowderCoating.Core.Entities.BugReport", b =>
{
b.Property<int>("Id")
@@ -1633,6 +1772,9 @@ namespace PowderCoating.Infrastructure.Migrations
b.Property<bool>("AiPhotoQuotesEnabled")
.HasColumnType("bit");
b.Property<DateTime?>("BookLockedThrough")
.HasColumnType("datetime2");
b.Property<string>("City")
.HasColumnType("nvarchar(max)");
@@ -1670,6 +1812,9 @@ namespace PowderCoating.Infrastructure.Migrations
b.Property<bool>("IsDeleted")
.HasColumnType("bit");
b.Property<string>("KioskActivationToken")
.HasColumnType("nvarchar(max)");
b.Property<string>("LogoContentType")
.HasColumnType("nvarchar(max)");
@@ -2108,6 +2253,10 @@ namespace PowderCoating.Infrastructure.Migrations
b.Property<int>("JobRetentionYears")
.HasColumnType("int");
b.Property<string>("KioskIntakeOutput")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<int>("LogRetentionDays")
.HasColumnType("int");
@@ -2750,6 +2899,9 @@ namespace PowderCoating.Infrastructure.Migrations
b.Property<string>("DeletedBy")
.HasColumnType("nvarchar(max)");
b.Property<int?>("DepositAccountId")
.HasColumnType("int");
b.Property<bool>("IsDeleted")
.HasColumnType("bit");
@@ -2994,6 +3146,142 @@ namespace PowderCoating.Infrastructure.Migrations
b.ToTable("Expenses");
});
modelBuilder.Entity("PowderCoating.Core.Entities.FixedAsset", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<int?>("AccumDepreciationAccountId")
.HasColumnType("int");
b.Property<decimal>("AccumulatedDepreciation")
.HasColumnType("decimal(18,2)");
b.Property<int?>("AssetAccountId")
.HasColumnType("int");
b.Property<int>("CompanyId")
.HasColumnType("int");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime2");
b.Property<string>("CreatedBy")
.HasColumnType("nvarchar(max)");
b.Property<DateTime?>("DeletedAt")
.HasColumnType("datetime2");
b.Property<string>("DeletedBy")
.HasColumnType("nvarchar(max)");
b.Property<int?>("DepreciationExpenseAccountId")
.HasColumnType("int");
b.Property<string>("Description")
.HasColumnType("nvarchar(max)");
b.Property<DateTime?>("DisposalDate")
.HasColumnType("datetime2");
b.Property<bool>("IsDeleted")
.HasColumnType("bit");
b.Property<bool>("IsDisposed")
.HasColumnType("bit");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<decimal>("PurchaseCost")
.HasColumnType("decimal(18,2)");
b.Property<DateTime>("PurchaseDate")
.HasColumnType("datetime2");
b.Property<decimal>("SalvageValue")
.HasColumnType("decimal(18,2)");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("datetime2");
b.Property<string>("UpdatedBy")
.HasColumnType("nvarchar(max)");
b.Property<int>("UsefulLifeMonths")
.HasColumnType("int");
b.HasKey("Id");
b.HasIndex("AccumDepreciationAccountId");
b.HasIndex("AssetAccountId");
b.HasIndex("DepreciationExpenseAccountId");
b.ToTable("FixedAssets");
});
modelBuilder.Entity("PowderCoating.Core.Entities.FixedAssetDepreciationEntry", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<decimal>("Amount")
.HasColumnType("decimal(18,2)");
b.Property<int>("CompanyId")
.HasColumnType("int");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime2");
b.Property<string>("CreatedBy")
.HasColumnType("nvarchar(max)");
b.Property<DateTime?>("DeletedAt")
.HasColumnType("datetime2");
b.Property<string>("DeletedBy")
.HasColumnType("nvarchar(max)");
b.Property<int>("FixedAssetId")
.HasColumnType("int");
b.Property<bool>("IsDeleted")
.HasColumnType("bit");
b.Property<int?>("JournalEntryId")
.HasColumnType("int");
b.Property<int>("PeriodMonth")
.HasColumnType("int");
b.Property<int>("PeriodYear")
.HasColumnType("int");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("datetime2");
b.Property<string>("UpdatedBy")
.HasColumnType("nvarchar(max)");
b.HasKey("Id");
b.HasIndex("FixedAssetId");
b.HasIndex("JournalEntryId");
b.ToTable("FixedAssetDepreciationEntries");
});
modelBuilder.Entity("PowderCoating.Core.Entities.GiftCertificate", b =>
{
b.Property<int>("Id")
@@ -3002,6 +3290,9 @@ namespace PowderCoating.Infrastructure.Migrations
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<Guid?>("BatchId")
.HasColumnType("uniqueidentifier");
b.Property<string>("CertificateCode")
.IsRequired()
.HasColumnType("nvarchar(450)");
@@ -3586,6 +3877,12 @@ namespace PowderCoating.Infrastructure.Migrations
b.Property<DateTime?>("DueDate")
.HasColumnType("datetime2");
b.Property<int>("EarlyPaymentDiscountDays")
.HasColumnType("int");
b.Property<decimal>("EarlyPaymentDiscountPercent")
.HasColumnType("decimal(18,2)");
b.Property<string>("ExternalReference")
.HasColumnType("nvarchar(450)");
@@ -3632,6 +3929,9 @@ namespace PowderCoating.Infrastructure.Migrations
b.Property<string>("PreparedById")
.HasColumnType("nvarchar(450)");
b.Property<string>("PublicViewToken")
.HasColumnType("nvarchar(max)");
b.Property<int?>("SalesTaxAccountId")
.HasColumnType("int");
@@ -3905,9 +4205,18 @@ namespace PowderCoating.Infrastructure.Migrations
b.Property<int?>("OriginalJobId")
.HasColumnType("int");
b.Property<decimal>("OvenBatchCost")
.HasColumnType("decimal(18,2)");
b.Property<int>("OvenBatches")
.HasColumnType("int");
b.Property<int?>("OvenCostId")
.HasColumnType("int");
b.Property<int?>("OvenCycleMinutes")
.HasColumnType("int");
b.Property<int?>("QuoteId")
.HasColumnType("int");
@@ -4176,6 +4485,9 @@ namespace PowderCoating.Infrastructure.Migrations
b.Property<bool>("IncludePrepCost")
.HasColumnType("bit");
b.Property<bool>("IsAiItem")
.HasColumnType("bit");
b.Property<bool>("IsDeleted")
.HasColumnType("bit");
@@ -5274,6 +5586,118 @@ namespace PowderCoating.Infrastructure.Migrations
b.ToTable("JournalEntryLines");
});
modelBuilder.Entity("PowderCoating.Core.Entities.KioskSession", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<bool>("AgreedToTerms")
.HasColumnType("bit");
b.Property<DateTime?>("AgreedToTermsAt")
.HasColumnType("datetime2");
b.Property<int>("CompanyId")
.HasColumnType("int");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime2");
b.Property<string>("CreatedBy")
.HasColumnType("nvarchar(max)");
b.Property<string>("CustomerEmail")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<string>("CustomerFirstName")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<string>("CustomerLastName")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<string>("CustomerPhone")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<DateTime?>("DeletedAt")
.HasColumnType("datetime2");
b.Property<string>("DeletedBy")
.HasColumnType("nvarchar(max)");
b.Property<DateTime>("ExpiresAt")
.HasColumnType("datetime2");
b.Property<string>("HowDidYouHearAboutUs")
.HasColumnType("nvarchar(max)");
b.Property<bool>("IsDeleted")
.HasColumnType("bit");
b.Property<bool>("IsReturningCustomer")
.HasColumnType("bit");
b.Property<string>("JobDescription")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<int?>("LinkedCustomerId")
.HasColumnType("int");
b.Property<int?>("LinkedJobId")
.HasColumnType("int");
b.Property<int?>("LinkedQuoteId")
.HasColumnType("int");
b.Property<string>("RemoteLinkEmail")
.HasColumnType("nvarchar(max)");
b.Property<DateTime?>("RemoteLinkSentAt")
.HasColumnType("datetime2");
b.Property<Guid>("SessionToken")
.HasColumnType("uniqueidentifier");
b.Property<int>("SessionType")
.HasColumnType("int");
b.Property<string>("SignatureDataBase64")
.HasColumnType("nvarchar(max)");
b.Property<bool>("SmsOptIn")
.HasColumnType("bit");
b.Property<int>("Status")
.HasColumnType("int");
b.Property<DateTime?>("SubmittedAt")
.HasColumnType("datetime2");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("datetime2");
b.Property<string>("UpdatedBy")
.HasColumnType("nvarchar(max)");
b.HasKey("Id");
b.HasIndex("LinkedCustomerId");
b.HasIndex("LinkedJobId");
b.HasIndex("SessionToken")
.IsUnique();
b.ToTable("KioskSessions");
});
modelBuilder.Entity("PowderCoating.Core.Entities.MaintenanceRecord", b =>
{
b.Property<int>("Id")
@@ -6287,7 +6711,7 @@ namespace PowderCoating.Infrastructure.Migrations
{
Id = 1,
CompanyId = 0,
CreatedAt = new DateTime(2026, 5, 10, 4, 6, 6, 200, DateTimeKind.Utc).AddTicks(8472),
CreatedAt = new DateTime(2026, 5, 15, 0, 30, 26, 273, DateTimeKind.Utc).AddTicks(2464),
Description = "Standard pricing for regular customers",
DiscountPercent = 0m,
IsActive = true,
@@ -6298,7 +6722,7 @@ namespace PowderCoating.Infrastructure.Migrations
{
Id = 2,
CompanyId = 0,
CreatedAt = new DateTime(2026, 5, 10, 4, 6, 6, 200, DateTimeKind.Utc).AddTicks(8478),
CreatedAt = new DateTime(2026, 5, 15, 0, 30, 26, 273, DateTimeKind.Utc).AddTicks(2473),
Description = "5% discount for preferred customers",
DiscountPercent = 5m,
IsActive = true,
@@ -6309,7 +6733,7 @@ namespace PowderCoating.Infrastructure.Migrations
{
Id = 3,
CompanyId = 0,
CreatedAt = new DateTime(2026, 5, 10, 4, 6, 6, 200, DateTimeKind.Utc).AddTicks(8479),
CreatedAt = new DateTime(2026, 5, 15, 0, 30, 26, 273, DateTimeKind.Utc).AddTicks(2474),
Description = "10% discount for premium customers",
DiscountPercent = 10m,
IsActive = true,
@@ -7266,6 +7690,78 @@ namespace PowderCoating.Infrastructure.Migrations
b.ToTable("QuoteStatusLookups");
});
modelBuilder.Entity("PowderCoating.Core.Entities.RecurringTemplate", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<int>("CompanyId")
.HasColumnType("int");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime2");
b.Property<string>("CreatedBy")
.HasColumnType("nvarchar(max)");
b.Property<DateTime?>("DeletedAt")
.HasColumnType("datetime2");
b.Property<string>("DeletedBy")
.HasColumnType("nvarchar(max)");
b.Property<DateTime?>("EndDate")
.HasColumnType("datetime2");
b.Property<int>("Frequency")
.HasColumnType("int");
b.Property<int>("IntervalCount")
.HasColumnType("int");
b.Property<bool>("IsActive")
.HasColumnType("bit");
b.Property<bool>("IsDeleted")
.HasColumnType("bit");
b.Property<string>("LastError")
.HasColumnType("nvarchar(max)");
b.Property<int?>("MaxOccurrences")
.HasColumnType("int");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<DateTime>("NextFireDate")
.HasColumnType("datetime2");
b.Property<int>("OccurrenceCount")
.HasColumnType("int");
b.Property<string>("TemplateData")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<int>("TemplateType")
.HasColumnType("int");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("datetime2");
b.Property<string>("UpdatedBy")
.HasColumnType("nvarchar(max)");
b.HasKey("Id");
b.ToTable("RecurringTemplates");
});
modelBuilder.Entity("PowderCoating.Core.Entities.Refund", b =>
{
b.Property<int>("Id")
@@ -7295,6 +7791,9 @@ namespace PowderCoating.Infrastructure.Migrations
b.Property<string>("DeletedBy")
.HasColumnType("nvarchar(max)");
b.Property<int?>("DepositAccountId")
.HasColumnType("int");
b.Property<int>("InvoiceId")
.HasColumnType("int");
@@ -7742,6 +8241,62 @@ namespace PowderCoating.Infrastructure.Migrations
b.ToTable("SubscriptionPlanConfigs");
});
modelBuilder.Entity("PowderCoating.Core.Entities.TaxRate", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<int>("CompanyId")
.HasColumnType("int");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime2");
b.Property<string>("CreatedBy")
.HasColumnType("nvarchar(max)");
b.Property<DateTime?>("DeletedAt")
.HasColumnType("datetime2");
b.Property<string>("DeletedBy")
.HasColumnType("nvarchar(max)");
b.Property<string>("Description")
.HasColumnType("nvarchar(max)");
b.Property<bool>("IsActive")
.HasColumnType("bit");
b.Property<bool>("IsDefault")
.HasColumnType("bit");
b.Property<bool>("IsDeleted")
.HasColumnType("bit");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<decimal>("Rate")
.HasColumnType("decimal(18,2)");
b.Property<string>("State")
.HasColumnType("nvarchar(max)");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("datetime2");
b.Property<string>("UpdatedBy")
.HasColumnType("nvarchar(max)");
b.HasKey("Id");
b.ToTable("TaxRates");
});
modelBuilder.Entity("PowderCoating.Core.Entities.TermsAcceptance", b =>
{
b.Property<int>("Id")
@@ -7876,6 +8431,9 @@ namespace PowderCoating.Infrastructure.Migrations
b.Property<string>("Email")
.HasColumnType("nvarchar(max)");
b.Property<bool>("Is1099Vendor")
.HasColumnType("bit");
b.Property<bool>("IsActive")
.HasColumnType("bit");
@@ -7966,6 +8524,9 @@ namespace PowderCoating.Infrastructure.Migrations
b.Property<string>("Memo")
.HasColumnType("nvarchar(max)");
b.Property<DateTime?>("PostedDate")
.HasColumnType("datetime2");
b.Property<decimal>("RemainingAmount")
.HasColumnType("decimal(18,2)");
@@ -8100,6 +8661,57 @@ namespace PowderCoating.Infrastructure.Migrations
b.ToTable("VendorCreditLineItems");
});
modelBuilder.Entity("PowderCoating.Core.Entities.YearEndClose", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<DateTime>("ClosedAt")
.HasColumnType("datetime2");
b.Property<string>("ClosedBy")
.HasColumnType("nvarchar(max)");
b.Property<int>("ClosedYear")
.HasColumnType("int");
b.Property<int>("CompanyId")
.HasColumnType("int");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime2");
b.Property<string>("CreatedBy")
.HasColumnType("nvarchar(max)");
b.Property<DateTime?>("DeletedAt")
.HasColumnType("datetime2");
b.Property<string>("DeletedBy")
.HasColumnType("nvarchar(max)");
b.Property<bool>("IsDeleted")
.HasColumnType("bit");
b.Property<int>("JournalEntryId")
.HasColumnType("int");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("datetime2");
b.Property<string>("UpdatedBy")
.HasColumnType("nvarchar(max)");
b.HasKey("Id");
b.HasIndex("JournalEntryId");
b.ToTable("YearEndCloses");
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<string>", b =>
{
b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null)
@@ -8313,6 +8925,25 @@ namespace PowderCoating.Infrastructure.Migrations
b.Navigation("Vendor");
});
modelBuilder.Entity("PowderCoating.Core.Entities.BudgetLine", b =>
{
b.HasOne("PowderCoating.Core.Entities.Account", "Account")
.WithMany()
.HasForeignKey("AccountId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("PowderCoating.Core.Entities.Budget", "Budget")
.WithMany("Lines")
.HasForeignKey("BudgetId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Account");
b.Navigation("Budget");
});
modelBuilder.Entity("PowderCoating.Core.Entities.BugReportAttachment", b =>
{
b.HasOne("PowderCoating.Core.Entities.BugReport", "BugReport")
@@ -8569,6 +9200,48 @@ namespace PowderCoating.Infrastructure.Migrations
b.Navigation("Vendor");
});
modelBuilder.Entity("PowderCoating.Core.Entities.FixedAsset", b =>
{
b.HasOne("PowderCoating.Core.Entities.Account", "AccumDepreciationAccount")
.WithMany()
.HasForeignKey("AccumDepreciationAccountId")
.OnDelete(DeleteBehavior.Restrict);
b.HasOne("PowderCoating.Core.Entities.Account", "AssetAccount")
.WithMany()
.HasForeignKey("AssetAccountId")
.OnDelete(DeleteBehavior.Restrict);
b.HasOne("PowderCoating.Core.Entities.Account", "DepreciationExpenseAccount")
.WithMany()
.HasForeignKey("DepreciationExpenseAccountId")
.OnDelete(DeleteBehavior.Restrict);
b.Navigation("AccumDepreciationAccount");
b.Navigation("AssetAccount");
b.Navigation("DepreciationExpenseAccount");
});
modelBuilder.Entity("PowderCoating.Core.Entities.FixedAssetDepreciationEntry", b =>
{
b.HasOne("PowderCoating.Core.Entities.FixedAsset", "FixedAsset")
.WithMany("DepreciationEntries")
.HasForeignKey("FixedAssetId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("PowderCoating.Core.Entities.JournalEntry", "JournalEntry")
.WithMany()
.HasForeignKey("JournalEntryId")
.OnDelete(DeleteBehavior.NoAction);
b.Navigation("FixedAsset");
b.Navigation("JournalEntry");
});
modelBuilder.Entity("PowderCoating.Core.Entities.GiftCertificate", b =>
{
b.HasOne("PowderCoating.Core.Entities.ApplicationUser", "IssuedBy")
@@ -9182,6 +9855,23 @@ namespace PowderCoating.Infrastructure.Migrations
b.Navigation("JournalEntry");
});
modelBuilder.Entity("PowderCoating.Core.Entities.KioskSession", b =>
{
b.HasOne("PowderCoating.Core.Entities.Customer", "LinkedCustomer")
.WithMany()
.HasForeignKey("LinkedCustomerId")
.OnDelete(DeleteBehavior.SetNull);
b.HasOne("PowderCoating.Core.Entities.Job", "LinkedJob")
.WithMany()
.HasForeignKey("LinkedJobId")
.OnDelete(DeleteBehavior.SetNull);
b.Navigation("LinkedCustomer");
b.Navigation("LinkedJob");
});
modelBuilder.Entity("PowderCoating.Core.Entities.MaintenanceRecord", b =>
{
b.HasOne("PowderCoating.Core.Entities.ApplicationUser", "AssignedUser")
@@ -9740,13 +10430,13 @@ namespace PowderCoating.Infrastructure.Migrations
b.HasOne("PowderCoating.Core.Entities.Bill", "Bill")
.WithMany()
.HasForeignKey("BillId")
.OnDelete(DeleteBehavior.Cascade)
.OnDelete(DeleteBehavior.NoAction)
.IsRequired();
b.HasOne("PowderCoating.Core.Entities.VendorCredit", "VendorCredit")
.WithMany("Applications")
.HasForeignKey("VendorCreditId")
.OnDelete(DeleteBehavior.Cascade)
.OnDelete(DeleteBehavior.NoAction)
.IsRequired();
b.Navigation("Bill");
@@ -9772,6 +10462,17 @@ namespace PowderCoating.Infrastructure.Migrations
b.Navigation("VendorCredit");
});
modelBuilder.Entity("PowderCoating.Core.Entities.YearEndClose", b =>
{
b.HasOne("PowderCoating.Core.Entities.JournalEntry", "JournalEntry")
.WithMany()
.HasForeignKey("JournalEntryId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("JournalEntry");
});
modelBuilder.Entity("PowderCoating.Core.Entities.Account", b =>
{
b.Navigation("BillLineItems");
@@ -9816,6 +10517,11 @@ namespace PowderCoating.Infrastructure.Migrations
b.Navigation("Payments");
});
modelBuilder.Entity("PowderCoating.Core.Entities.Budget", b =>
{
b.Navigation("Lines");
});
modelBuilder.Entity("PowderCoating.Core.Entities.BugReport", b =>
{
b.Navigation("Attachments");
@@ -9878,6 +10584,11 @@ namespace PowderCoating.Infrastructure.Migrations
b.Navigation("OvenBatches");
});
modelBuilder.Entity("PowderCoating.Core.Entities.FixedAsset", b =>
{
b.Navigation("DepreciationEntries");
});
modelBuilder.Entity("PowderCoating.Core.Entities.GiftCertificate", b =>
{
b.Navigation("Redemptions");
@@ -121,6 +121,9 @@ public class UnitOfWork : IUnitOfWork
private IRepository<GiftCertificate>? _giftCertificates;
private IRepository<GiftCertificateRedemption>? _giftCertificateRedemptions;
// Customer Intake Kiosk
private IRepository<KioskSession>? _kioskSessions;
// Purchase Orders
private IPurchaseOrderRepository? _purchaseOrders;
private IRepository<PurchaseOrderItem>? _purchaseOrderItems;
@@ -154,6 +157,17 @@ public class UnitOfWork : IUnitOfWork
// Bank Reconciliation
private IRepository<BankReconciliation>? _bankReconciliations;
// Tax Rates
private IRepository<TaxRate>? _taxRates;
// Recurring Transactions
private IRepository<RecurringTemplate>? _recurringTemplates;
private IRepository<FixedAsset>? _fixedAssets;
private IRepository<FixedAssetDepreciationEntry>? _fixedAssetDepreciationEntries;
private IRepository<Budget>? _budgets;
private IRepository<BudgetLine>? _budgetLines;
private IRepository<YearEndClose>? _yearEndCloses;
/// <summary>
/// Initialises the unit of work with the scoped <paramref name="context"/>.
/// The context is shared across all repositories created by this instance so that
@@ -449,6 +463,10 @@ public class UnitOfWork : IUnitOfWork
public IRepository<GiftCertificateRedemption> GiftCertificateRedemptions =>
_giftCertificateRedemptions ??= new Repository<GiftCertificateRedemption>(_context);
/// <summary>Repository for <see cref="KioskSession"/> customer self-service intake sessions; tenant-filtered with soft delete.</summary>
public IRepository<KioskSession> KioskSessions =>
_kioskSessions ??= new Repository<KioskSession>(_context);
// Job Templates
/// <summary>Repository for <see cref="JobTemplate"/> reusable job blueprints; tenant-filtered with soft delete.</summary>
public IJobTemplateRepository JobTemplates =>
@@ -552,6 +570,26 @@ public class UnitOfWork : IUnitOfWork
public IRepository<BankReconciliation> BankReconciliations =>
_bankReconciliations ??= new Repository<BankReconciliation>(_context);
// Tax Rates
/// <summary>Repository for <see cref="TaxRate"/> named tax rates used to pre-fill invoice tax percent by jurisdiction.</summary>
public IRepository<TaxRate> TaxRates =>
_taxRates ??= new Repository<TaxRate>(_context);
// Recurring Transactions
/// <summary>Repository for <see cref="RecurringTemplate"/> — saved recipes that auto-generate bills or expenses on a schedule.</summary>
public IRepository<RecurringTemplate> RecurringTemplates =>
_recurringTemplates ??= new Repository<RecurringTemplate>(_context);
public IRepository<FixedAsset> FixedAssets =>
_fixedAssets ??= new Repository<FixedAsset>(_context);
public IRepository<FixedAssetDepreciationEntry> FixedAssetDepreciationEntries =>
_fixedAssetDepreciationEntries ??= new Repository<FixedAssetDepreciationEntry>(_context);
public IRepository<Budget> Budgets =>
_budgets ??= new Repository<Budget>(_context);
public IRepository<BudgetLine> BudgetLines =>
_budgetLines ??= new Repository<BudgetLine>(_context);
public IRepository<YearEndClose> YearEndCloses =>
_yearEndCloses ??= new Repository<YearEndClose>(_context);
/// <summary>
/// Flushes all pending changes in the EF Core change tracker to the database.
/// Returns the number of state entries written.
@@ -902,4 +902,454 @@ Account Spend Trends (this month vs historical):
return new AnomalyDetectionResult { Success = false, ErrorMessage = "An error occurred while running the analysis." };
}
}
// ── Feature 7: Bank Rec Auto-Match ────────────────────────────────────────
/// <summary>
/// Suggests which uncleared bank rec transactions to mark as cleared to close the gap
/// between the current running balance and the statement ending balance. The items list
/// includes both deposits and payments with their direction tag so Claude can reason about
/// net effect. Confidence scores reflect how cleanly each item contributes to reaching the
/// target ending balance — items that together sum close to the required difference score
/// higher than items that alone overshoot. MaxTokens is 1024; the response is typically
/// compact because we only need entity-type/id pairs plus a short reason per item.
/// </summary>
public async Task<AutoMatchResult> AutoMatchReconciliationAsync(AutoMatchRequest request)
{
var apiKey = GetApiKey();
if (apiKey == null)
return new AutoMatchResult { Success = false, ErrorMessage = "Anthropic API key is not configured." };
try
{
var systemPrompt = @"You are a bank reconciliation assistant for a powder coating business.
Given a list of uncleared transactions and a target statement ending balance, suggest which transactions
to mark as cleared so that: Beginning Balance + cleared deposits - cleared payments = Statement Ending Balance.
Respond ONLY with a valid JSON object no markdown, no explanation.
Schema:
{
""suggestedCleared"": [
{
""entityType"": ""Payment"" | ""BillPayment"" | ""Expense"",
""entityId"": number,
""confidence"": number (0.0 to 1.0),
""reason"": ""string one sentence why this item should be cleared""
}
],
""insights"": [""string"", ...]
}
Rules:
- Select the combination of items whose net effect (deposits minus payments) gets closest to the difference needed
- Difference needed = statementEndingBalance - beginningBalance
- confidence 0.9-1.0: item clearly belongs in this period (date and amount both fit)
- confidence 0.6-0.89: likely but not certain
- confidence below 0.6: possible but uncertain include only if needed to close the gap
- insights: 2-4 observations about patterns or items that need manual review
- Do NOT suggest clearing items you are uncertain about just to force a zero balance";
var itemsJson = JsonSerializer.Serialize(request.UnclearedItems);
var needed = request.StatementEndingBalance - request.BeginningBalance;
var userPrompt = $@"Suggest which transactions to clear for this bank reconciliation.
Beginning Balance: {request.BeginningBalance:F2}
Statement Ending Balance: {request.StatementEndingBalance:F2}
Difference needed (deposits - payments): {needed:F2}
Uncleared transactions:
{itemsJson}";
var client = new AnthropicClient(apiKey);
var messageParams = new MessageParameters
{
Model = Model,
MaxTokens = 1024,
SystemMessage = systemPrompt,
Messages = new List<Message>
{
new Message
{
Role = RoleType.User,
Content = new List<ContentBase> { new TextContent { Text = userPrompt } }
}
}
};
var response = await SendAsync(client, messageParams);
var rawText = response.FirstMessage?.Text
?? response.Content?.OfType<TextContent>().FirstOrDefault()?.Text
?? "";
if (string.IsNullOrWhiteSpace(rawText))
return new AutoMatchResult { Success = false, ErrorMessage = "Empty response from AI." };
var raw = StripJsonFences(rawText);
var parsed = JsonSerializer.Deserialize<ClaudeAutoMatchResponse>(raw, JsonOpts);
if (parsed == null)
return new AutoMatchResult { Success = false, ErrorMessage = "Could not parse AI response." };
return new AutoMatchResult
{
Success = true,
SuggestedCleared = (parsed.SuggestedCleared ?? new()).Select(s => new AutoMatchSuggestion
{
EntityType = s.EntityType,
EntityId = s.EntityId,
Confidence = s.Confidence,
Reason = s.Reason
}).ToList(),
Insights = parsed.Insights ?? new()
};
}
catch (OperationCanceledException)
{
_logger.LogWarning("Claude AI bank rec auto-match timed out after 60 seconds");
return new AutoMatchResult { Success = false, ErrorMessage = "The AI service did not respond in time. Please try again." };
}
catch (Exception ex)
{
_logger.LogError(ex, "Error running bank rec auto-match with AI");
return new AutoMatchResult { Success = false, ErrorMessage = "An error occurred while running auto-match." };
}
}
// ── Feature 8: Late Payment Prediction ────────────────────────────────────
/// <summary>
/// Predicts payment risk per open AR customer by combining current overdue status with
/// historical behavior metrics (avg days to pay, late rate). The late rate is pre-calculated
/// as LateInvoicesAllTime / TotalInvoicesAllTime so Claude receives a 01 ratio rather than
/// raw counts, which produces more consistent confidence scoring across customers with very
/// different invoice volumes. Risk levels are validated against the three allowed values and
/// default to "medium" when Claude returns anything outside the expected set.
/// </summary>
public async Task<LatePaymentPredictionResult> PredictLatePaymentsAsync(LatePaymentPredictionRequest request)
{
var apiKey = GetApiKey();
if (apiKey == null)
return new LatePaymentPredictionResult { Success = false, ErrorMessage = "Anthropic API key is not configured." };
try
{
var systemPrompt = @"You are an accounts receivable risk analyst for a powder coating business.
Given open AR data and each customer's historical payment behavior, predict payment risk for each customer.
Respond ONLY with a valid JSON object no markdown, no explanation.
Schema:
{
""predictions"": [
{
""customerName"": ""string"",
""riskLevel"": ""high"" | ""medium"" | ""low"",
""estimatedDaysToPayment"": number,
""reasoning"": ""string one sentence explaining the prediction""
}
],
""insights"": [""string"", ...]
}
Rules:
- riskLevel ""high"": customer has a history of late payment AND is already overdue, or has a very high late rate
- riskLevel ""medium"": customer is overdue but has reasonable historical performance, or is current but has a spotty history
- riskLevel ""low"": customer typically pays on time and is not severely overdue
- estimatedDaysToPayment: realistic estimate of additional days until payment, based on history and overdue status
- insights: 2-4 portfolio-level observations (e.g. which customers need immediate follow-up)
- Only include predictions for customers with open invoices";
var customersJson = JsonSerializer.Serialize(request.Customers.Select(c => new
{
c.CustomerName,
c.TotalOwed,
c.AvgDaysToPay,
LatePaymentRate = c.TotalInvoicesAllTime > 0
? Math.Round((double)c.LateInvoicesAllTime / c.TotalInvoicesAllTime, 2)
: 0,
c.OpenInvoices
}));
var userPrompt = $@"Predict payment risk for open AR customers of {request.CompanyName}.
Customer data (includes historical payment behavior):
{customersJson}";
var client = new AnthropicClient(apiKey);
var messageParams = new MessageParameters
{
Model = Model,
MaxTokens = 1024,
SystemMessage = systemPrompt,
Messages = new List<Message>
{
new Message
{
Role = RoleType.User,
Content = new List<ContentBase> { new TextContent { Text = userPrompt } }
}
}
};
var response = await SendAsync(client, messageParams);
var rawText = response.FirstMessage?.Text
?? response.Content?.OfType<TextContent>().FirstOrDefault()?.Text
?? "";
if (string.IsNullOrWhiteSpace(rawText))
return new LatePaymentPredictionResult { Success = false, ErrorMessage = "Empty response from AI." };
var raw = StripJsonFences(rawText);
var parsed = JsonSerializer.Deserialize<ClaudeLatePaymentResponse>(raw, JsonOpts);
if (parsed == null)
return new LatePaymentPredictionResult { Success = false, ErrorMessage = "Could not parse AI response." };
var validRiskLevels = new[] { "high", "medium", "low" };
var predictions = (parsed.Predictions ?? new()).Select(p => new LatePaymentPrediction
{
CustomerName = p.CustomerName,
RiskLevel = validRiskLevels.Contains(p.RiskLevel?.ToLowerInvariant()) ? p.RiskLevel!.ToLowerInvariant() : "medium",
EstimatedDaysToPayment = p.EstimatedDaysToPayment,
Reasoning = p.Reasoning
}).ToList();
return new LatePaymentPredictionResult
{
Success = true,
Predictions = predictions,
Insights = parsed.Insights ?? new()
};
}
catch (OperationCanceledException)
{
_logger.LogWarning("Claude AI late payment prediction timed out after 60 seconds");
return new LatePaymentPredictionResult { Success = false, ErrorMessage = "The AI service did not respond in time. Please try again." };
}
catch (Exception ex)
{
_logger.LogError(ex, "Error predicting late payments with AI");
return new LatePaymentPredictionResult { Success = false, ErrorMessage = "An error occurred while predicting payment risk." };
}
}
// ── Feature 9: Natural Language Financial Queries ─────────────────────────
/// <summary>
/// Answers a free-text financial question using a pre-loaded snapshot of the company's
/// financial data. The context object is serialized to JSON and embedded in the user prompt
/// so Claude has concrete numbers to reason over rather than fabricating estimates. The
/// system prompt explicitly constrains Claude to the data provided and forbids it from
/// making up figures outside the snapshot — this prevents hallucination of specific dollar
/// amounts. RelevantFacts is a list of supporting data points Claude pulled from the context
/// to justify the answer, displayed below the answer in the UI so users can verify.
/// MaxTokens is raised to 1500 to accommodate answers with multiple supporting facts.
/// </summary>
public async Task<FinancialQueryResult> AnswerFinancialQueryAsync(FinancialQueryRequest request)
{
var apiKey = GetApiKey();
if (apiKey == null)
return new FinancialQueryResult { Success = false, ErrorMessage = "Anthropic API key is not configured." };
try
{
var systemPrompt = @"You are a financial analyst assistant for a powder coating business.
Answer plain-English financial questions using ONLY the data provided in the context.
Respond ONLY with a valid JSON object no markdown, no explanation.
Schema:
{
""answer"": ""string direct, plain-English answer to the question"",
""followUpSuggestion"": ""string one optional follow-up question the user might want to ask next, or null"",
""relevantFacts"": [""string"", ...]
}
Rules:
- answer: be direct and specific with dollar amounts and percentages from the data
- If the data does not contain enough information to answer the question, say so clearly in the answer
- Do NOT invent or estimate figures that are not in the provided data
- relevantFacts: 2-5 specific data points from the context that support the answer (formatted as ""Label: $X"" or ""Label: X%"")
- followUpSuggestion: suggest the natural next question the user would want to ask, or null if not obvious
- Keep the answer under 100 words be concise";
var contextJson = JsonSerializer.Serialize(request.Context);
var userPrompt = $@"Question: {request.Question}
Financial context:
{contextJson}";
var client = new AnthropicClient(apiKey);
var messageParams = new MessageParameters
{
Model = Model,
MaxTokens = 1500,
SystemMessage = systemPrompt,
Messages = new List<Message>
{
new Message
{
Role = RoleType.User,
Content = new List<ContentBase> { new TextContent { Text = userPrompt } }
}
}
};
var response = await SendAsync(client, messageParams);
var rawText = response.FirstMessage?.Text
?? response.Content?.OfType<TextContent>().FirstOrDefault()?.Text
?? "";
if (string.IsNullOrWhiteSpace(rawText))
return new FinancialQueryResult { Success = false, ErrorMessage = "Empty response from AI." };
var raw = StripJsonFences(rawText);
var parsed = JsonSerializer.Deserialize<ClaudeFinancialQueryResponse>(raw, JsonOpts);
if (parsed == null)
return new FinancialQueryResult { Success = false, ErrorMessage = "Could not parse AI response." };
return new FinancialQueryResult
{
Success = true,
Answer = parsed.Answer ?? string.Empty,
FollowUpSuggestion = parsed.FollowUpSuggestion,
RelevantFacts = parsed.RelevantFacts ?? new()
};
}
catch (OperationCanceledException)
{
_logger.LogWarning("Claude AI financial query timed out after 60 seconds");
return new FinancialQueryResult { Success = false, ErrorMessage = "The AI service did not respond in time. Please try again." };
}
catch (Exception ex)
{
_logger.LogError(ex, "Error answering financial query with AI");
return new FinancialQueryResult { Success = false, ErrorMessage = "An error occurred while answering your question." };
}
}
// ── Feature 10: Recurring Bill Detection ──────────────────────────────────
/// <summary>
/// Analyzes 612 months of historical bills to detect recurring payment patterns per vendor.
/// Bills are grouped by vendor in the prompt so Claude can see the full chronological series
/// for each vendor at a glance. The confidence field ("high"/"medium"/"low") reflects how
/// regular the cadence is — a bill appearing every 2832 days for 6 consecutive months is
/// high confidence; 23 occurrences at similar amounts is medium. NextExpectedDateIso is
/// calculated by Claude from the pattern's most recent date plus the detected period length.
/// MaxTokens is 1500 to accommodate multi-vendor response objects with multiple patterns.
/// </summary>
public async Task<RecurringBillDetectionResult> DetectRecurringBillsAsync(RecurringBillDetectionRequest request)
{
var apiKey = GetApiKey();
if (apiKey == null)
return new RecurringBillDetectionResult { Success = false, ErrorMessage = "Anthropic API key is not configured." };
try
{
var systemPrompt = @"You are a recurring expense analyst for a powder coating business.
Analyze the provided bill history to detect recurring payment patterns per vendor.
Respond ONLY with a valid JSON object no markdown, no explanation.
Schema:
{
""patterns"": [
{
""vendorName"": ""string"",
""frequency"": ""monthly"" | ""quarterly"" | ""biannual"" | ""annual"" | ""irregular"",
""typicalAmount"": number,
""nextExpectedDateIso"": ""YYYY-MM-DD or null"",
""confidence"": ""high"" | ""medium"" | ""low"",
""description"": ""string one sentence describing the pattern"",
""suggestedAction"": ""string one specific action to take, or null""
}
],
""insights"": [""string"", ...]
}
Rules:
- Only report patterns with at least 2 occurrences
- monthly: bills occurring every 2535 days
- quarterly: bills occurring every 80100 days
- biannual: bills occurring every 170195 days
- annual: bills occurring roughly once per year
- irregular: a vendor bills regularly but the cadence is inconsistent
- confidence ""high"": 4+ occurrences with consistent timing (within ±5 days of the period)
- confidence ""medium"": 23 occurrences with consistent timing, or 4+ with variable timing
- confidence ""low"": pattern is weak but worth monitoring
- nextExpectedDateIso: estimate based on the last bill date + the detected period; null if irregular or low confidence
- suggestedAction: e.g. ""Set a monthly reminder for this bill"" or ""Create a recurring bill template"" or null
- insights: 2-4 portfolio-level observations about the company's recurring expense profile
- If no recurring patterns are found, return an empty patterns array";
// Group bills by vendor for clarity in the prompt
var grouped = request.Bills
.GroupBy(b => b.VendorName)
.Select(g => new
{
VendorName = g.Key,
Bills = g.OrderBy(b => b.DateIso).Select(b => new { b.DateIso, b.Amount, b.BillNumber, b.Memo })
});
var billsJson = JsonSerializer.Serialize(grouped);
var userPrompt = $@"Detect recurring bill patterns for {request.CompanyName}.
Data covers the last 612 months of bills, grouped by vendor.
Bill history by vendor:
{billsJson}";
var client = new AnthropicClient(apiKey);
var messageParams = new MessageParameters
{
Model = Model,
MaxTokens = 1500,
SystemMessage = systemPrompt,
Messages = new List<Message>
{
new Message
{
Role = RoleType.User,
Content = new List<ContentBase> { new TextContent { Text = userPrompt } }
}
}
};
var response = await SendAsync(client, messageParams);
var rawText = response.FirstMessage?.Text
?? response.Content?.OfType<TextContent>().FirstOrDefault()?.Text
?? "";
if (string.IsNullOrWhiteSpace(rawText))
return new RecurringBillDetectionResult { Success = false, ErrorMessage = "Empty response from AI." };
var raw = StripJsonFences(rawText);
var parsed = JsonSerializer.Deserialize<ClaudeRecurringBillResponse>(raw, JsonOpts);
if (parsed == null)
return new RecurringBillDetectionResult { Success = false, ErrorMessage = "Could not parse AI response." };
var validConfidence = new[] { "high", "medium", "low" };
var validFrequency = new[] { "monthly", "quarterly", "biannual", "annual", "irregular" };
return new RecurringBillDetectionResult
{
Success = true,
Patterns = (parsed.Patterns ?? new()).Select(p => new RecurringBillPattern
{
VendorName = p.VendorName,
Frequency = validFrequency.Contains(p.Frequency?.ToLowerInvariant()) ? p.Frequency!.ToLowerInvariant() : "irregular",
TypicalAmount = p.TypicalAmount,
NextExpectedDateIso = p.NextExpectedDateIso,
Confidence = validConfidence.Contains(p.Confidence?.ToLowerInvariant()) ? p.Confidence!.ToLowerInvariant() : "medium",
Description = p.Description,
SuggestedAction = p.SuggestedAction
}).ToList(),
Insights = parsed.Insights ?? new()
};
}
catch (OperationCanceledException)
{
_logger.LogWarning("Claude AI recurring bill detection timed out after 60 seconds");
return new RecurringBillDetectionResult { Success = false, ErrorMessage = "The AI service did not respond in time. Please try again." };
}
catch (Exception ex)
{
_logger.LogError(ex, "Error detecting recurring bills with AI");
return new RecurringBillDetectionResult { Success = false, ErrorMessage = "An error occurred while analyzing bill patterns." };
}
}
}
@@ -435,7 +435,15 @@ Only ask follow-up questions if truly needed — prefer to make reasonable assum
shopSpeedLine = "- Shop blast rate: not calibrated — use conservative industry-average times for this shop tier";
}
var coatingSpeedLine = $"- THIS SHOP'S coating application rate: ~{coatingRate:F0} sqft/hr";
string coatingSpeedLine;
if (coatingRate > 0)
coatingSpeedLine = $"- THIS SHOP'S coating application rate: ~{coatingRate:F0} sqft/hr — use this to derive coating time (surface area ÷ coating rate), NOT generic industry averages";
else
coatingSpeedLine = "- Shop coating rate: not calibrated — use conservative industry-average coating times for this shop tier";
var rateInstruction = (blastRate > 0 || coatingRate > 0)
? "IMPORTANT: For estimatedMinutes, you MUST use this shop's specific rates above where provided, not generic industry speeds."
: "IMPORTANT: For estimatedMinutes, use conservative industry-average times appropriate for a professional powder coating shop.";
return $@"Please analyze the item(s) in the photo(s) for powder coating estimation.
@@ -453,7 +461,7 @@ Company operating costs for your reference:
{shopSpeedLine}
{coatingSpeedLine}
IMPORTANT: For estimatedMinutes, you MUST use this shop's specific blast and coating rates above, not generic industry speeds.
{rateInstruction}
Sandblasting time = surface area of item ÷ shop blast rate (sqft/hr), adjusted for part complexity (harder-to-reach areas take more passes).
Coating time = surface area ÷ shop coating rate, adjusted for masking and complexity.
Include racking/unracking, inspection, and any material-specific prep (preheat handling, chemical stripping) as ACTIVE labor time.
@@ -547,9 +555,9 @@ Respond with the JSON object only.";
_ => 0
};
// Labor cost — AI returns total batch minutes, so divide by quantity to get per-item minutes.
// The unit price × quantity must equal the total batch labor cost.
var rawPerItemMinutes = aiResult.EstimatedMinutes / Math.Max(1m, (decimal)request.Quantity);
// Labor cost — AI returns per-item minutes (both system prompt and user prompt say "per single item").
// Unit price is per item; the caller multiplies by quantity for the line total.
var rawPerItemMinutes = aiResult.EstimatedMinutes;
var minFloorApplied = materialMinMinutes > 0 && rawPerItemMinutes < materialMinMinutes;
var perItemMinutes = minFloorApplied ? materialMinMinutes : rawPerItemMinutes;
var laborHours = perItemMinutes / 60m;
@@ -611,7 +619,7 @@ Respond with the JSON object only.";
CoatCount = request.CoatCount,
MaterialCost = Math.Round(materialCost, 2),
ConsumablesCost = Math.Round(consumablesSurcharge, 2),
EstimatedMinutes = (int)Math.Round(perItemMinutes),
EstimatedMinutes = perItemMinutes,
MaterialMinMinutes = materialMinMinutes,
MinFloorApplied = minFloorApplied,
LaborCost = Math.Round(laborCost, 2),
@@ -137,6 +137,16 @@ public class ApplicationUserClaimsPrincipalFactory : UserClaimsPrincipalFactory<
identity.AddClaim(new Claim("Permission", "ViewReports"));
}
if (user.CanManageBills)
{
identity.AddClaim(new Claim("Permission", "ManageBills"));
}
if (user.CanManageAccounting)
{
identity.AddClaim(new Claim("Permission", "ManageAccounting"));
}
return identity;
}
}
@@ -1,5 +1,6 @@
using Microsoft.EntityFrameworkCore;
using PowderCoating.Core.Entities;
using PowderCoating.Core.Enums;
using PowderCoating.Core.Interfaces.Services;
using PowderCoating.Infrastructure.Data;
@@ -21,15 +22,34 @@ public class CompanyListService : ICompanyListService
}
/// <inheritdoc/>
public async Task<(List<Company> Companies, int TotalCount)> GetPagedAsync(
string? searchTerm, string sortColumn, string sortDirection, int page, int pageSize)
public async Task<(List<Company> Companies, int TotalCount, int ChurnedCount)> GetPagedAsync(
string? searchTerm, string sortColumn, string sortDirection, int page, int pageSize,
bool hideChurned = true)
{
var cutoff = DateTime.UtcNow.AddDays(-14);
// Always count churned regardless of hideChurned so the banner can show a number.
var churnedCount = await _context.Companies
.AsNoTracking()
.IgnoreQueryFilters()
.Where(c => !c.IsDeleted
&& (c.SubscriptionStatus == SubscriptionStatus.Expired || c.SubscriptionStatus == SubscriptionStatus.Canceled)
&& c.SubscriptionEndDate != null
&& c.SubscriptionEndDate < cutoff)
.CountAsync();
var query = _context.Companies
.AsNoTracking()
.IgnoreQueryFilters()
.Where(c => !c.IsDeleted)
.AsQueryable();
if (hideChurned)
query = query.Where(c =>
!((c.SubscriptionStatus == SubscriptionStatus.Expired || c.SubscriptionStatus == SubscriptionStatus.Canceled)
&& c.SubscriptionEndDate != null
&& c.SubscriptionEndDate < cutoff));
if (!string.IsNullOrWhiteSpace(searchTerm))
{
var s = searchTerm.ToLower();
@@ -61,12 +81,16 @@ public class CompanyListService : ICompanyListService
.Take(pageSize)
.ToListAsync();
return (companies, totalCount);
return (companies, totalCount, churnedCount);
}
/// <inheritdoc/>
public async Task<CompanyCountSummary> GetCountSummaryAsync(IReadOnlyList<int> companyIds)
{
var now = DateTime.UtcNow;
var d30 = now.AddDays(-30);
var d90 = now.AddDays(-90);
var jobCounts = await _context.Jobs
.IgnoreQueryFilters()
.Where(j => companyIds.Contains(j.CompanyId) && !j.IsDeleted)
@@ -98,6 +122,32 @@ public class CompanyListService : ICompanyListService
x => x.CompanyId,
x => new CompanyWizardInfo(true, x.SetupWizardCompletedAt, x.SetupWizardCompletedByName));
return new CompanyCountSummary(jobCounts, quoteCounts, customerCounts, wizardInfo);
var jobs30 = await _context.Jobs
.IgnoreQueryFilters()
.Where(j => companyIds.Contains(j.CompanyId) && !j.IsDeleted && j.CreatedAt >= d30)
.GroupBy(j => j.CompanyId)
.Select(g => new { g.Key, Count = g.Count() })
.ToDictionaryAsync(x => x.Key, x => x.Count);
var jobs90 = await _context.Jobs
.IgnoreQueryFilters()
.Where(j => companyIds.Contains(j.CompanyId) && !j.IsDeleted && j.CreatedAt >= d90)
.GroupBy(j => j.CompanyId)
.Select(g => new { g.Key, Count = g.Count() })
.ToDictionaryAsync(x => x.Key, x => x.Count);
var lastLoginRaw = await _context.Users
.IgnoreQueryFilters()
.Where(u => companyIds.Contains(u.CompanyId) && u.LastLoginDate != null)
.GroupBy(u => u.CompanyId)
.Select(g => new { CompanyId = g.Key, Last = g.Max(u => u.LastLoginDate) })
.ToListAsync();
var lastLogins = lastLoginRaw.ToDictionary(
x => x.CompanyId,
x => x.Last);
return new CompanyCountSummary(jobCounts, quoteCounts, customerCounts, wizardInfo,
jobs30, jobs90, lastLogins);
}
}
@@ -79,6 +79,53 @@ public class FinancialReportService : IFinancialReportService
.SumAsync(ii => (decimal?)ii.TotalPrice) ?? 0;
if (unlinkedRevenue > 0)
revenueLines.Add(new FinancialReportLine { AccountNumber = "—", AccountName = "Other Sales (unclassified)", Amount = unlinkedRevenue });
// Contra-revenue: discounts granted and credit memos applied reduce gross revenue.
var periodDiscounts = await _context.Invoices
.Where(i => i.Status != InvoiceStatus.Draft && i.Status != InvoiceStatus.Voided
&& i.DiscountAmount > 0 && i.InvoiceDate >= from && i.InvoiceDate <= toEnd)
.SumAsync(i => (decimal?)i.DiscountAmount) ?? 0m;
var periodCredits = await _context.CreditMemoApplications
.Where(a => a.AppliedDate >= from && a.AppliedDate <= toEnd
&& a.Invoice.Status != InvoiceStatus.Voided)
.SumAsync(a => (decimal?)a.AmountApplied) ?? 0m;
var totalDeductions = periodDiscounts + periodCredits;
if (totalDeductions > 0)
revenueLines.Add(new FinancialReportLine
{
AccountNumber = "4950",
AccountName = "Less: Sales Discounts & Credits",
Amount = -totalDeductions
});
// GC sales are deferred to GC Liability at issuance; revenue is recognized on redemption.
var periodGcReclassified = await _context.InvoiceItems
.Where(ii => ii.IsGiftCertificate
&& ii.Invoice.Status != InvoiceStatus.Draft
&& ii.Invoice.Status != InvoiceStatus.Voided
&& ii.Invoice.InvoiceDate >= from && ii.Invoice.InvoiceDate <= toEnd)
.SumAsync(ii => (decimal?)ii.TotalPrice) ?? 0m;
if (periodGcReclassified > 0)
revenueLines.Add(new FinancialReportLine
{
AccountNumber = "2500",
AccountName = "Less: Gift Certificates Issued (Deferred Revenue)",
Amount = -periodGcReclassified
});
// Voided GCs with remaining balance are breakage income (liability extinguished).
var periodGcBreakage = await _context.GiftCertificates
.Where(gc => !gc.IsDeleted && gc.Status == GiftCertificateStatus.Voided
&& gc.UpdatedAt >= from && gc.UpdatedAt <= toEnd
&& gc.OriginalAmount > gc.RedeemedAmount)
.SumAsync(gc => (decimal?)(gc.OriginalAmount - gc.RedeemedAmount)) ?? 0m;
if (periodGcBreakage > 0)
revenueLines.Add(new FinancialReportLine
{
AccountNumber = "—",
AccountName = "Gift Certificate Breakage Income",
Amount = periodGcBreakage
});
}
// COGS & Expenses — cash basis: expenses paid in period; accrual: by bill/expense date
@@ -200,6 +247,13 @@ public class FinancialReportService : IFinancialReportService
.Select(g => new { Id = g.Key, Amount = g.Sum(bp => bp.Amount) })
.ToDictionaryAsync(g => g.Id, g => g.Amount);
// AP: vendor credit applications reduce AP (DR side) when matched against specific bills.
var vcByApAcctBs = await _context.VendorCreditApplications
.Where(vca => vca.AppliedDate <= asOfEnd)
.GroupBy(vca => vca.VendorCredit.APAccountId)
.Select(g => new { Id = g.Key, Amount = g.Sum(vca => vca.Amount) })
.ToDictionaryAsync(g => g.Id, g => g.Amount);
var taxByAcct = await _context.Invoices
.Where(i => i.SalesTaxAccountId != null && i.TaxAmount > 0
&& i.Status != InvoiceStatus.Draft && i.Status != InvoiceStatus.Voided
@@ -216,18 +270,131 @@ public class FinancialReportService : IFinancialReportService
&& p.Invoice.Status != InvoiceStatus.Voided
&& p.Invoice.Status != InvoiceStatus.WrittenOff)
.SumAsync(p => (decimal?)p.Amount) ?? 0;
// Credit memo applications reduce open AR (CR AR when a credit is applied to an invoice).
arCredits += await _context.CreditMemoApplications
.Where(a => a.AppliedDate <= asOfEnd && a.Invoice.Status != InvoiceStatus.Voided)
.SumAsync(a => (decimal?)a.AmountApplied) ?? 0;
// Refunds reverse collected payments — they re-open AR so reduce net AR credits.
arCredits -= await _context.Refunds
.Where(r => r.RefundDate <= asOfEnd && !r.IsDeleted)
.SumAsync(r => (decimal?)r.Amount) ?? 0m;
// Retained earnings = net P&L from inception through asOf
// Refunds by bank account: money that left the account (CR to checking/bank).
var refundsByAcctBs = await _context.Refunds
.Where(r => r.RefundDate <= asOfEnd && !r.IsDeleted && r.DepositAccountId != null)
.GroupBy(r => r.DepositAccountId!.Value)
.Select(g => new { Id = g.Key, Amount = g.Sum(r => r.Amount) })
.ToDictionaryAsync(g => g.Id, g => g.Amount);
// Deposits by bank account: cash received at deposit recording time (DR bank).
var depositsByAcctDepBs = await _context.Deposits
.Where(d => !d.IsDeleted && d.DepositAccountId != null && d.ReceivedDate <= asOfEnd)
.GroupBy(d => d.DepositAccountId!.Value)
.Select(g => new { Id = g.Key, Amount = g.Sum(d => d.Amount) })
.ToDictionaryAsync(g => g.Id, g => g.Amount);
// Customer Deposits liability (2300): credits = all deposits taken; debits = deposits applied to invoices.
var custDepositsAcctIdBs = await _context.Accounts
.Where(a => a.CompanyId == companyId && a.AccountNumber == "2300" && a.IsActive && !a.IsDeleted)
.Select(a => (int?)a.Id).FirstOrDefaultAsync();
var custDepositsCreditsBs = custDepositsAcctIdBs.HasValue
? (await _context.Deposits
.Where(d => !d.IsDeleted && d.ReceivedDate <= asOfEnd)
.SumAsync(d => (decimal?)d.Amount) ?? 0m) : 0m;
var custDepositsDebitsBs = custDepositsAcctIdBs.HasValue
? (await _context.Deposits
.Where(d => !d.IsDeleted && d.AppliedToInvoiceId != null && d.AppliedDate <= asOfEnd)
.SumAsync(d => (decimal?)d.Amount) ?? 0m) : 0m;
// Gift Certificate Liability (2500): balance driven by GC issuances, redemptions, and voids.
var gcLiabilityAcctIdBs = await _context.Accounts
.Where(a => a.CompanyId == companyId && a.AccountNumber == "2500" && a.IsActive && !a.IsDeleted)
.Select(a => (int?)a.Id).FirstOrDefaultAsync();
var gcLiabilityCreditsBs = gcLiabilityAcctIdBs.HasValue
? (await _context.GiftCertificates
.Where(gc => !gc.IsDeleted && gc.IssueDate <= asOfEnd)
.SumAsync(gc => (decimal?)gc.OriginalAmount) ?? 0m) : 0m;
var gcLiabilityDebitsBs = gcLiabilityAcctIdBs.HasValue
? ((await _context.GiftCertificateRedemptions
.Where(r => !r.IsDeleted && r.RedeemedDate <= asOfEnd)
.SumAsync(r => (decimal?)r.AmountRedeemed) ?? 0m)
+ (await _context.GiftCertificates
.Where(gc => !gc.IsDeleted && gc.Status == GiftCertificateStatus.Voided
&& gc.UpdatedAt <= asOfEnd && gc.OriginalAmount > gc.RedeemedAmount)
.SumAsync(gc => (decimal?)(gc.OriginalAmount - gc.RedeemedAmount)) ?? 0m)) : 0m;
// Retained earnings = net P&L from inception through asOf, covering four sources:
// (1) invoice revenue, (2) invoice discounts, (3) direct expenses, (4) vendor bill costs,
// plus (5) the net effect of any posted journal entries on revenue/expense/COGS accounts
// (accruals, depreciation, year-end closes, and other adjustments not in the tables above).
var lifetimeRevenue = await _context.InvoiceItems
.Where(ii => ii.Invoice.Status != InvoiceStatus.Draft && ii.Invoice.Status != InvoiceStatus.Voided && ii.Invoice.InvoiceDate <= asOfEnd)
.SumAsync(ii => (decimal?)ii.TotalPrice) ?? 0;
var lifetimeCogs = await _context.Expenses
var lifetimeDiscounts = isCash ? 0m
: (await _context.Invoices
.Where(i => i.Status != InvoiceStatus.Draft && i.Status != InvoiceStatus.Voided
&& i.DiscountAmount > 0 && i.InvoiceDate <= asOfEnd)
.SumAsync(i => (decimal?)i.DiscountAmount) ?? 0m);
// Credit memos applied to invoices reduce net revenue (contra-revenue, same as discounts).
var lifetimeCreditMemos = isCash ? 0m
: (await _context.CreditMemoApplications
.Where(a => a.AppliedDate <= asOfEnd && a.Invoice.Status != InvoiceStatus.Voided)
.SumAsync(a => (decimal?)a.AmountApplied) ?? 0m);
var lifetimeDirectExp = await _context.Expenses
.Where(e => e.Date <= asOfEnd)
.SumAsync(e => (decimal?)e.Amount) ?? 0;
var lifetimeBillCosts = await _context.BillLineItems
.Where(bli => bli.Bill.Status != BillStatus.Draft && bli.Bill.Status != BillStatus.Voided && bli.Bill.BillDate <= asOfEnd)
.SumAsync(bli => (decimal?)bli.Amount) ?? 0;
var retainedEarnings = lifetimeRevenue - lifetimeCogs - lifetimeBillCosts;
// JE net effect on revenue accounts (positive = additional revenue recognised via manual JE)
var revenueAcctIds = await _context.Accounts
.Where(a => a.CompanyId == companyId && a.AccountType == AccountType.Revenue && !a.IsDeleted)
.Select(a => a.Id).ToListAsync();
var expCogsAcctIds = await _context.Accounts
.Where(a => a.CompanyId == companyId
&& (a.AccountType == AccountType.Expense || a.AccountType == AccountType.CostOfGoods)
&& !a.IsDeleted)
.Select(a => a.Id).ToListAsync();
var jeRevNet = revenueAcctIds.Count > 0
? (await _context.JournalEntryLines
.Where(l => revenueAcctIds.Contains(l.AccountId)
&& l.JournalEntry.Status == JournalEntryStatus.Posted
&& l.JournalEntry.EntryDate <= asOfEnd)
.SumAsync(l => (decimal?)(l.CreditAmount - l.DebitAmount)) ?? 0m)
: 0m;
// JE net effect on expense/COGS accounts (positive = additional expense recognised via manual JE)
var jeExpNet = expCogsAcctIds.Count > 0
? (await _context.JournalEntryLines
.Where(l => expCogsAcctIds.Contains(l.AccountId)
&& l.JournalEntry.Status == JournalEntryStatus.Posted
&& l.JournalEntry.EntryDate <= asOfEnd)
.SumAsync(l => (decimal?)(l.DebitAmount - l.CreditAmount)) ?? 0m)
: 0m;
// GC items sold via invoices are reclassified to GC Liability and not yet earned income.
var lifetimeGcReclassified = await _context.InvoiceItems
.Where(ii => ii.IsGiftCertificate
&& ii.Invoice.Status != InvoiceStatus.Draft
&& ii.Invoice.Status != InvoiceStatus.Voided
&& ii.Invoice.InvoiceDate <= asOfEnd)
.SumAsync(ii => (decimal?)ii.TotalPrice) ?? 0m;
// Voided GCs with remaining balance become breakage income (the liability is extinguished).
var lifetimeGcBreakage = await _context.GiftCertificates
.Where(gc => !gc.IsDeleted && gc.Status == GiftCertificateStatus.Voided
&& gc.UpdatedAt <= asOfEnd && gc.OriginalAmount > gc.RedeemedAmount)
.SumAsync(gc => (decimal?)(gc.OriginalAmount - gc.RedeemedAmount)) ?? 0m;
var retainedEarnings = lifetimeRevenue + jeRevNet
- lifetimeDiscounts
- lifetimeCreditMemos
- lifetimeGcReclassified // deferred to GC Liability, not earned yet
+ lifetimeGcBreakage // breakage income when GC voided with balance
- lifetimeDirectExp
- lifetimeBillCosts
- jeExpNet;
var accounts = await _context.Accounts
.Where(a => a.IsActive)
@@ -248,6 +415,7 @@ public class FinancialReportService : IFinancialReportService
{
credits = billsByApAcct.GetValueOrDefault(a.Id);
debits = bpByApAcct.GetValueOrDefault(a.Id);
debits += vcByApAcctBs.GetValueOrDefault(a.Id); // vendor credit applications reduce AP
}
else
{
@@ -255,6 +423,18 @@ public class FinancialReportService : IFinancialReportService
credits += expFromByAcct.GetValueOrDefault(a.Id);
credits += bpFromByAcct.GetValueOrDefault(a.Id);
credits += taxByAcct.GetValueOrDefault(a.Id);
credits += refundsByAcctBs.GetValueOrDefault(a.Id); // refunds reduce bank balance
debits += depositsByAcctDepBs.GetValueOrDefault(a.Id); // deposits increase bank balance
if (gcLiabilityAcctIdBs.HasValue && a.Id == gcLiabilityAcctIdBs.Value)
{
credits += gcLiabilityCreditsBs; // GC issued → CR liability
debits += gcLiabilityDebitsBs; // redeemed/voided → DR liability
}
if (custDepositsAcctIdBs.HasValue && a.Id == custDepositsAcctIdBs.Value)
{
credits += custDepositsCreditsBs; // deposits taken → CR liability
debits += custDepositsDebitsBs; // deposits applied → DR liability
}
}
decimal opening = (a.OpeningBalanceDate == null || a.OpeningBalanceDate.Value.Date <= asOf)
@@ -652,20 +832,277 @@ public class FinancialReportService : IFinancialReportService
}
/// <inheritdoc/>
/// <remarks>
/// Balances are computed dynamically from transaction tables using the same pre-computed
/// dictionary approach as <see cref="GetBalanceSheetAsync"/>, so the <paramref name="asOf"/>
/// date is respected. This replaces the previous implementation that read the denormalised
/// <c>Account.CurrentBalance</c> field, which always reflected the current date regardless of
/// what date was selected.
/// </remarks>
public async Task<TrialBalanceDto> GetTrialBalanceAsync(int companyId, DateTime asOf)
{
var asOfEnd = asOf.AddDays(1).AddTicks(-1);
var companyName = await GetCompanyNameAsync(companyId);
// ── Pre-compute per-account contribution dictionaries (batch GROUP BY, no N+1) ──────
// Bank/cash: customer payments deposited here (DR)
var depositsByAcct = await _context.Payments
.Where(p => p.PaymentDate <= asOfEnd && p.DepositAccountId != null
&& p.Invoice.Status != InvoiceStatus.Voided
&& p.Invoice.Status != InvoiceStatus.WrittenOff)
.GroupBy(p => p.DepositAccountId!.Value)
.Select(g => new { Id = g.Key, Amt = g.Sum(p => p.Amount) })
.ToDictionaryAsync(g => g.Id, g => g.Amt);
// AP: vendor credit applications reduce AP (DR) — credits are applied when a vendor
// issues a credit note and it is matched against a specific bill.
var vcByApAcct = await _context.VendorCreditApplications
.Where(vca => vca.AppliedDate <= asOfEnd)
.GroupBy(vca => vca.VendorCredit.APAccountId)
.Select(g => new { Id = g.Key, Amt = g.Sum(vca => vca.Amount) })
.ToDictionaryAsync(g => g.Id, g => g.Amt);
// Bank/cash: expenses paid from here (CR)
var expFromByAcct = await _context.Expenses
.Where(e => e.Date <= asOfEnd)
.GroupBy(e => e.PaymentAccountId)
.Select(g => new { Id = g.Key, Amt = g.Sum(e => e.Amount) })
.ToDictionaryAsync(g => g.Id, g => g.Amt);
// Bank/cash: bill payments made from here (CR)
var bpFromByAcct = await _context.BillPayments
.Where(bp => bp.PaymentDate <= asOfEnd)
.GroupBy(bp => bp.BankAccountId)
.Select(g => new { Id = g.Key, Amt = g.Sum(bp => bp.Amount) })
.ToDictionaryAsync(g => g.Id, g => g.Amt);
// AP: bills increase AP (CR)
var billsByApAcct = await _context.Bills
.Where(b => b.Status != BillStatus.Draft && b.Status != BillStatus.Voided && b.BillDate <= asOfEnd)
.GroupBy(b => b.APAccountId)
.Select(g => new { Id = g.Key, Amt = g.Sum(b => b.Total) })
.ToDictionaryAsync(g => g.Id, g => g.Amt);
// AP: bill payments reduce AP (DR)
var bpByApAcct = await _context.BillPayments
.Where(bp => bp.PaymentDate <= asOfEnd)
.GroupBy(bp => bp.Bill.APAccountId)
.Select(g => new { Id = g.Key, Amt = g.Sum(bp => bp.Amount) })
.ToDictionaryAsync(g => g.Id, g => g.Amt);
// Tax liability: sales tax collected (CR)
var taxByAcct = await _context.Invoices
.Where(i => i.SalesTaxAccountId != null && i.TaxAmount > 0
&& i.Status != InvoiceStatus.Draft && i.Status != InvoiceStatus.Voided
&& i.InvoiceDate <= asOfEnd)
.GroupBy(i => i.SalesTaxAccountId!.Value)
.Select(g => new { Id = g.Key, Amt = g.Sum(i => i.TaxAmount) })
.ToDictionaryAsync(g => g.Id, g => g.Amt);
// Revenue accounts: invoice line items (CR)
var revenueByAcct = await _context.InvoiceItems
.Where(ii => ii.RevenueAccountId != null
&& ii.Invoice.Status != InvoiceStatus.Draft
&& ii.Invoice.Status != InvoiceStatus.Voided
&& ii.Invoice.InvoiceDate <= asOfEnd)
.GroupBy(ii => ii.RevenueAccountId!.Value)
.Select(g => new { Id = g.Key, Amt = g.Sum(ii => ii.TotalPrice) })
.ToDictionaryAsync(g => g.Id, g => g.Amt);
// Expense accounts: direct expenses (DR)
var expenseByAcct = await _context.Expenses
.Where(e => e.Date <= asOfEnd)
.GroupBy(e => e.ExpenseAccountId)
.Select(g => new { Id = g.Key, Amt = g.Sum(e => e.Amount) })
.ToDictionaryAsync(g => g.Id, g => g.Amt);
// Expense/COGS accounts: vendor bill line items (DR)
var billLinesByAcct = await _context.BillLineItems
.Where(bli => bli.AccountId != null
&& bli.Bill.Status != BillStatus.Draft
&& bli.Bill.Status != BillStatus.Voided
&& bli.Bill.BillDate <= asOfEnd)
.GroupBy(bli => bli.AccountId!.Value)
.Select(g => new { Id = g.Key, Amt = g.Sum(bli => bli.Amount) })
.ToDictionaryAsync(g => g.Id, g => g.Amt);
// Sales Discounts contra-revenue account: invoice discounts and credit memo applications (DR).
// Both reduce net revenue and are attributed to account 4950 as contra-revenue debits.
// Credit memo applications are also added to AR credits below so the double-entry balances.
var discountAcctId = await _context.Accounts
.Where(a => a.CompanyId == companyId && a.AccountNumber == "4950" && a.IsActive && !a.IsDeleted)
.Select(a => (int?)a.Id)
.FirstOrDefaultAsync();
discountAcctId ??= await _context.Accounts
.Where(a => a.CompanyId == companyId && a.AccountType == AccountType.Revenue
&& a.IsActive && !a.IsDeleted && a.Name.ToLower().Contains("discount"))
.Select(a => (int?)a.Id)
.FirstOrDefaultAsync();
var cmApplied = await _context.CreditMemoApplications
.Where(a => a.AppliedDate <= asOfEnd
&& a.Invoice.Status != InvoiceStatus.Voided)
.SumAsync(a => (decimal?)a.AmountApplied) ?? 0m;
var discountsByAcct = new Dictionary<int, decimal>();
if (discountAcctId.HasValue)
{
var totalDiscounts = await _context.Invoices
.Where(i => i.DiscountAmount > 0
&& i.Status != InvoiceStatus.Draft
&& i.Status != InvoiceStatus.Voided
&& i.InvoiceDate <= asOfEnd)
.SumAsync(i => (decimal?)i.DiscountAmount) ?? 0m;
if (totalDiscounts + cmApplied > 0)
discountsByAcct[discountAcctId.Value] = totalDiscounts + cmApplied;
}
// JE lines: posted entries debit/credit all account types
var jeDebitsByAcct = await _context.JournalEntryLines
.Where(l => l.JournalEntry.Status == JournalEntryStatus.Posted
&& l.JournalEntry.EntryDate <= asOfEnd)
.GroupBy(l => l.AccountId)
.Select(g => new { Id = g.Key, Amt = g.Sum(l => l.DebitAmount) })
.ToDictionaryAsync(g => g.Id, g => g.Amt);
var jeCreditsByAcct = await _context.JournalEntryLines
.Where(l => l.JournalEntry.Status == JournalEntryStatus.Posted
&& l.JournalEntry.EntryDate <= asOfEnd)
.GroupBy(l => l.AccountId)
.Select(g => new { Id = g.Key, Amt = g.Sum(l => l.CreditAmount) })
.ToDictionaryAsync(g => g.Id, g => g.Amt);
// AR totals (single AR account assumed per standard small-business chart of accounts).
// Credits include both cash payments and credit memo applications (which reduce open AR
// when a customer credit is applied against a specific invoice).
var arTotalDebits = await _context.Invoices
.Where(i => i.Status != InvoiceStatus.Draft && i.Status != InvoiceStatus.Voided
&& i.InvoiceDate <= asOfEnd)
.SumAsync(i => (decimal?)i.Total) ?? 0m;
var arTotalCredits = await _context.Payments
.Where(p => p.PaymentDate <= asOfEnd
&& p.Invoice.Status != InvoiceStatus.Voided
&& p.Invoice.Status != InvoiceStatus.WrittenOff)
.SumAsync(p => (decimal?)p.Amount) ?? 0m;
arTotalCredits += cmApplied; // credit memo applications reduce AR balance
// Refunds reverse collected payments — reduce net AR credits (re-opens the receivable).
var refundTotal = await _context.Refunds
.Where(r => r.RefundDate <= asOfEnd && !r.IsDeleted)
.SumAsync(r => (decimal?)r.Amount) ?? 0m;
arTotalCredits -= refundTotal;
// Refunds by bank account: money leaving the account (CR to checking/bank).
var refundsByAcct = await _context.Refunds
.Where(r => r.RefundDate <= asOfEnd && !r.IsDeleted && r.DepositAccountId != null)
.GroupBy(r => r.DepositAccountId!.Value)
.Select(g => new { Id = g.Key, Amt = g.Sum(r => r.Amount) })
.ToDictionaryAsync(g => g.Id, g => g.Amt);
// Deposits by bank account: cash received at deposit recording time (DR bank).
// Deposit-sourced Payments have DepositAccountId = null, so there is no double-count with depositsByAcct.
var depositsByAcctDep = await _context.Deposits
.Where(d => !d.IsDeleted && d.DepositAccountId != null && d.ReceivedDate <= asOfEnd)
.GroupBy(d => d.DepositAccountId!.Value)
.Select(g => new { Id = g.Key, Amt = g.Sum(d => d.Amount) })
.ToDictionaryAsync(g => g.Id, g => g.Amt);
// Customer Deposits liability (2300): credits = all deposits taken; debits = deposits applied to invoices.
var custDepositsAcctId = await _context.Accounts
.Where(a => a.CompanyId == companyId && a.AccountNumber == "2300" && a.IsActive && !a.IsDeleted)
.Select(a => (int?)a.Id).FirstOrDefaultAsync();
var custDepositsCredits = custDepositsAcctId.HasValue
? (await _context.Deposits
.Where(d => !d.IsDeleted && d.ReceivedDate <= asOfEnd)
.SumAsync(d => (decimal?)d.Amount) ?? 0m) : 0m;
var custDepositsDebits = custDepositsAcctId.HasValue
? (await _context.Deposits
.Where(d => !d.IsDeleted && d.AppliedToInvoiceId != null && d.AppliedDate <= asOfEnd)
.SumAsync(d => (decimal?)d.Amount) ?? 0m) : 0m;
// Gift Certificate Liability (2500): balance driven by GC issuances, redemptions, and voids.
var gcLiabilityAcctId = await _context.Accounts
.Where(a => a.CompanyId == companyId && a.AccountNumber == "2500" && a.IsActive && !a.IsDeleted)
.Select(a => (int?)a.Id).FirstOrDefaultAsync();
var gcLiabilityCredits = gcLiabilityAcctId.HasValue
? (await _context.GiftCertificates
.Where(gc => !gc.IsDeleted && gc.IssueDate <= asOfEnd)
.SumAsync(gc => (decimal?)gc.OriginalAmount) ?? 0m) : 0m;
var gcLiabilityDebits = gcLiabilityAcctId.HasValue
? ((await _context.GiftCertificateRedemptions
.Where(r => !r.IsDeleted && r.RedeemedDate <= asOfEnd)
.SumAsync(r => (decimal?)r.AmountRedeemed) ?? 0m)
+ (await _context.GiftCertificates
.Where(gc => !gc.IsDeleted && gc.Status == GiftCertificateStatus.Voided
&& gc.UpdatedAt <= asOfEnd && gc.OriginalAmount > gc.RedeemedAmount)
.SumAsync(gc => (decimal?)(gc.OriginalAmount - gc.RedeemedAmount)) ?? 0m)) : 0m;
// ── Per-account balance computation ─────────────────────────────────────────────────
var accounts = await _context.Accounts
.Where(a => a.CompanyId == companyId && a.IsActive)
.OrderBy(a => a.AccountNumber)
.ToListAsync();
var lines = new List<TrialBalanceLine>();
decimal ComputeAsOfBalance(Account a)
{
bool isDebitNormal = AccountingRules.IsNormalDebitBalance(a.AccountSubType);
decimal debits = 0m, credits = 0m;
if (a.AccountSubType == AccountSubType.AccountsReceivable)
{
debits = arTotalDebits;
credits = arTotalCredits;
}
else if (a.AccountSubType == AccountSubType.AccountsPayable)
{
credits = billsByApAcct.GetValueOrDefault(a.Id);
debits = bpByApAcct.GetValueOrDefault(a.Id);
debits += vcByApAcct.GetValueOrDefault(a.Id); // vendor credit applications reduce AP
}
else
{
// All other accounts: sum contributions from each transaction source that can
// post to this account. Dictionaries only contain entries for relevant account IDs,
// so GetValueOrDefault returns 0 for sources that do not apply to this account type.
debits += depositsByAcct.GetValueOrDefault(a.Id);
credits += expFromByAcct.GetValueOrDefault(a.Id);
credits += bpFromByAcct.GetValueOrDefault(a.Id);
credits += taxByAcct.GetValueOrDefault(a.Id);
credits += revenueByAcct.GetValueOrDefault(a.Id);
debits += expenseByAcct.GetValueOrDefault(a.Id);
debits += billLinesByAcct.GetValueOrDefault(a.Id);
debits += discountsByAcct.GetValueOrDefault(a.Id);
credits += refundsByAcct.GetValueOrDefault(a.Id); // refunds reduce bank balance
debits += depositsByAcctDep.GetValueOrDefault(a.Id); // deposits increase bank balance
if (gcLiabilityAcctId.HasValue && a.Id == gcLiabilityAcctId.Value)
{
credits += gcLiabilityCredits; // GC issued → CR liability
debits += gcLiabilityDebits; // redeemed/voided → DR liability
}
if (custDepositsAcctId.HasValue && a.Id == custDepositsAcctId.Value)
{
credits += custDepositsCredits; // deposits taken → CR liability
debits += custDepositsDebits; // deposits applied → DR liability
}
}
// Manual JEs apply to all account types (including AR/AP for unusual adjustments)
debits += jeDebitsByAcct.GetValueOrDefault(a.Id);
credits += jeCreditsByAcct.GetValueOrDefault(a.Id);
decimal opening = (a.OpeningBalanceDate == null || a.OpeningBalanceDate.Value.Date <= asOf)
? a.OpeningBalance : 0m;
decimal net = isDebitNormal ? debits - credits : credits - debits;
return opening + net;
}
var lines = new List<TrialBalanceLine>();
foreach (var acct in accounts)
{
if (acct.CurrentBalance == 0) continue;
var balance = ComputeAsOfBalance(acct);
if (balance == 0m) continue;
var isDebitNormal = AccountingRules.IsNormalDebitBalance(acct.AccountSubType);
var line = new TrialBalanceLine
@@ -679,14 +1116,14 @@ public class FinancialReportService : IFinancialReportService
if (isDebitNormal)
{
// Normal debit: positive balance → Debit column; negative → Credit column (abnormal)
if (acct.CurrentBalance >= 0) line.DebitBalance = acct.CurrentBalance;
else line.CreditBalance = -acct.CurrentBalance;
if (balance >= 0m) line.DebitBalance = balance;
else line.CreditBalance = -balance;
}
else
{
// Normal credit: positive balance → Credit column; negative → Debit column (abnormal)
if (acct.CurrentBalance >= 0) line.CreditBalance = acct.CurrentBalance;
else line.DebitBalance = -acct.CurrentBalance;
if (balance >= 0m) line.CreditBalance = balance;
else line.DebitBalance = -balance;
}
lines.Add(line);
@@ -713,6 +1150,326 @@ public class FinancialReportService : IFinancialReportService
return method ?? AccountingMethod.Accrual;
}
/// <inheritdoc/>
public async Task<CustomerStatementDto> GetCustomerStatementAsync(int companyId, int customerId, DateTime from, DateTime to)
{
var toEnd = to.AddDays(1).AddTicks(-1);
var fromEnd = from.AddTicks(-1); // exclusive upper bound for pre-period queries
var companyName = await GetCompanyNameAsync(companyId);
var customer = await _context.Customers
.Where(c => c.Id == customerId && c.CompanyId == companyId)
.AsNoTracking().FirstOrDefaultAsync();
if (customer == null) return new CustomerStatementDto { CompanyName = companyName, From = from, To = to };
var customerName = customer.IsCommercial
? customer.CompanyName ?? string.Empty
: $"{customer.ContactFirstName} {customer.ContactLastName}".Trim();
var address = string.Join(", ", new[] { customer.Address, customer.City, customer.State, customer.ZipCode }
.Where(s => !string.IsNullOrWhiteSpace(s)));
// Opening balance: invoiced paid before period start
var preInvoiced = await _context.Invoices
.Where(i => i.CustomerId == customerId
&& i.Status != InvoiceStatus.Draft && i.Status != InvoiceStatus.Voided
&& i.InvoiceDate < from)
.SumAsync(i => (decimal?)i.Total) ?? 0;
var prePaid = await _context.Payments
.Where(p => p.Invoice.CustomerId == customerId
&& p.Invoice.Status != InvoiceStatus.Voided
&& p.PaymentDate < from)
.SumAsync(p => (decimal?)p.Amount) ?? 0;
var preCredits = await _context.CreditMemoApplications
.Where(a => a.Invoice.CustomerId == customerId && a.AppliedDate < from)
.SumAsync(a => (decimal?)a.AmountApplied) ?? 0;
var openingBalance = preInvoiced - prePaid - preCredits;
// In-period activity — gather, then sort, then compute running balance
var lines = new List<StatementLineDto>();
var periodInvoices = await _context.Invoices
.Where(i => i.CustomerId == customerId
&& i.Status != InvoiceStatus.Draft && i.Status != InvoiceStatus.Voided
&& i.InvoiceDate >= from && i.InvoiceDate <= toEnd)
.AsNoTracking().ToListAsync();
foreach (var inv in periodInvoices)
lines.Add(new StatementLineDto
{
Date = inv.InvoiceDate,
Type = "Invoice",
Reference = inv.InvoiceNumber,
Description = "Invoice",
Debit = inv.Total,
});
var periodPayments = await _context.Payments
.Include(p => p.Invoice)
.Where(p => p.Invoice.CustomerId == customerId
&& p.Invoice.Status != InvoiceStatus.Voided
&& p.PaymentDate >= from && p.PaymentDate <= toEnd)
.AsNoTracking().ToListAsync();
foreach (var pay in periodPayments)
lines.Add(new StatementLineDto
{
Date = pay.PaymentDate,
Type = "Payment",
Reference = pay.Invoice.InvoiceNumber,
Description = pay.Notes ?? "Payment received",
Credit = pay.Amount,
});
var periodCredits = await _context.CreditMemoApplications
.Include(a => a.Invoice)
.Include(a => a.CreditMemo)
.Where(a => a.Invoice.CustomerId == customerId
&& a.AppliedDate >= from && a.AppliedDate <= toEnd)
.AsNoTracking().ToListAsync();
foreach (var cr in periodCredits)
lines.Add(new StatementLineDto
{
Date = cr.AppliedDate,
Type = "Credit Applied",
Reference = cr.Invoice?.InvoiceNumber ?? string.Empty,
Description = $"Credit memo applied",
Credit = cr.AmountApplied,
});
// Sort by date then compute running balance
lines = lines.OrderBy(l => l.Date).ThenBy(l => l.Type).ToList();
var running = openingBalance;
foreach (var line in lines)
{
running += (line.Debit ?? 0) - (line.Credit ?? 0);
line.RunningBalance = running;
}
return new CustomerStatementDto
{
CustomerId = customerId,
CustomerName = customerName,
CustomerAddress = address,
CompanyName = companyName,
From = from,
To = to,
OpeningBalance = openingBalance,
Lines = lines,
ClosingBalance = running,
};
}
/// <inheritdoc/>
public async Task<VendorStatementDto> GetVendorStatementAsync(int companyId, int vendorId, DateTime from, DateTime to)
{
var toEnd = to.AddDays(1).AddTicks(-1);
var companyName = await GetCompanyNameAsync(companyId);
var vendor = await _context.Vendors
.Where(v => v.Id == vendorId && v.CompanyId == companyId)
.AsNoTracking().FirstOrDefaultAsync();
if (vendor == null) return new VendorStatementDto { CompanyName = companyName, From = from, To = to };
// Opening balance: bills payments credits before period start
var preBills = await _context.Bills
.Where(b => b.VendorId == vendorId
&& b.Status != BillStatus.Draft && b.Status != BillStatus.Voided
&& b.BillDate < from)
.SumAsync(b => (decimal?)b.Total) ?? 0;
var prePayments = await _context.BillPayments
.Where(bp => bp.Bill.VendorId == vendorId && bp.PaymentDate < from)
.SumAsync(bp => (decimal?)bp.Amount) ?? 0;
var preVcApplied = await _context.VendorCreditApplications
.Where(vca => vca.Bill.VendorId == vendorId && vca.AppliedDate < from)
.SumAsync(vca => (decimal?)vca.Amount) ?? 0;
var openingBalance = preBills - prePayments - preVcApplied;
var lines = new List<StatementLineDto>();
var periodBills = await _context.Bills
.Where(b => b.VendorId == vendorId
&& b.Status != BillStatus.Draft && b.Status != BillStatus.Voided
&& b.BillDate >= from && b.BillDate <= toEnd)
.AsNoTracking().ToListAsync();
foreach (var bill in periodBills)
lines.Add(new StatementLineDto
{
Date = bill.BillDate,
Type = "Bill",
Reference = bill.BillNumber,
Description = bill.Memo ?? "Vendor bill",
Debit = bill.Total,
});
var periodPayments = await _context.BillPayments
.Include(bp => bp.Bill)
.Where(bp => bp.Bill.VendorId == vendorId
&& bp.PaymentDate >= from && bp.PaymentDate <= toEnd)
.AsNoTracking().ToListAsync();
foreach (var pay in periodPayments)
lines.Add(new StatementLineDto
{
Date = pay.PaymentDate,
Type = "Payment",
Reference = pay.Bill.BillNumber,
Description = pay.Memo ?? "Bill payment",
Credit = pay.Amount,
});
var periodVcApplied = await _context.VendorCreditApplications
.Include(vca => vca.VendorCredit)
.Include(vca => vca.Bill)
.Where(vca => vca.Bill.VendorId == vendorId
&& vca.AppliedDate >= from && vca.AppliedDate <= toEnd)
.AsNoTracking().ToListAsync();
foreach (var vca in periodVcApplied)
lines.Add(new StatementLineDto
{
Date = vca.AppliedDate,
Type = "Credit Applied",
Reference = vca.VendorCredit.CreditNumber,
Description = $"Vendor credit applied to {vca.Bill.BillNumber}",
Credit = vca.Amount,
});
lines = lines.OrderBy(l => l.Date).ThenBy(l => l.Type).ToList();
var running = openingBalance;
foreach (var line in lines)
{
running += (line.Debit ?? 0) - (line.Credit ?? 0);
line.RunningBalance = running;
}
return new VendorStatementDto
{
VendorId = vendorId,
VendorName = vendor.CompanyName,
CompanyName = companyName,
From = from,
To = to,
OpeningBalance = openingBalance,
Lines = lines,
ClosingBalance = running,
};
}
/// <inheritdoc/>
/// <summary>
/// Computes a Cash Flow Statement for the given period using the direct (cash-basis) method
/// for operating activities:
/// <list type="bullet">
/// <item><b>CashFromCustomers</b> — sum of <see cref="Payment"/> amounts in the period.</item>
/// <item><b>CashToVendors</b> — sum of <see cref="BillPayment"/> amounts in the period.</item>
/// <item><b>CashForExpenses</b> — sum of <see cref="Expense"/> amounts in the period.</item>
/// </list>
/// BeginningCash is derived by summing all Payment inflows minus BillPayment and Expense outflows
/// prior to <paramref name="from"/>. This is an approximation when cash accounts have
/// an OpeningBalance; it is the most accurate representation available without a dedicated
/// cash-tracking journal.
/// Investing and Financing sections are populated from the expense/asset account ledger
/// (FixedAsset purchases from Expense entries whose account is FixedAsset subtype) and
/// equity account changes respectively.
/// </summary>
public async Task<CashFlowStatementDto> GetCashFlowStatementAsync(int companyId, DateTime from, DateTime to)
{
var toEnd = to.Date.AddDays(1).AddTicks(-1);
var companyName = await GetCompanyNameAsync(companyId);
var method = await GetCompanyAccountingMethodAsync(companyId);
// ── Operating — direct / cash ──────────────────────────────────────
var cashFromCustomers = await _context.Payments
.IgnoreQueryFilters()
.Where(p => p.CompanyId == companyId && !p.IsDeleted
&& p.PaymentDate >= from && p.PaymentDate <= toEnd)
.SumAsync(p => (decimal?)p.Amount) ?? 0m;
var cashToVendors = await _context.BillPayments
.IgnoreQueryFilters()
.Where(bp => bp.CompanyId == companyId && !bp.IsDeleted
&& bp.PaymentDate >= from && bp.PaymentDate <= toEnd)
.SumAsync(bp => (decimal?)bp.Amount) ?? 0m;
var cashForExpenses = await _context.Expenses
.IgnoreQueryFilters()
.Where(e => e.CompanyId == companyId && !e.IsDeleted
&& e.Date >= from && e.Date <= toEnd)
.SumAsync(e => (decimal?)e.Amount) ?? 0m;
// ── Investing — fixed-asset purchases from Expense entries ─────────
var fixedAssetAccountIds = await _context.Accounts
.IgnoreQueryFilters()
.Where(a => a.CompanyId == companyId && !a.IsDeleted
&& a.AccountSubType == AccountSubType.FixedAsset)
.Select(a => a.Id)
.ToListAsync();
var capEx = fixedAssetAccountIds.Count > 0
? (await _context.Expenses
.IgnoreQueryFilters()
.Where(e => e.CompanyId == companyId && !e.IsDeleted
&& e.Date >= from && e.Date <= toEnd
&& fixedAssetAccountIds.Contains(e.ExpenseAccountId))
.SumAsync(e => (decimal?)e.Amount) ?? 0m)
: 0m;
var investingLines = new List<CashFlowLineDto>();
if (capEx != 0m)
investingLines.Add(new CashFlowLineDto { Label = "Capital Expenditures", Amount = -capEx });
// ── Financing — placeholder (equity changes not explicitly tracked) ─
var financingLines = new List<CashFlowLineDto>();
// ── Beginning cash ─────────────────────────────────────────────────
// Cash account opening balances + pre-period payments in - pre-period payments out
var cashAccountOpeningBalance = await _context.Accounts
.IgnoreQueryFilters()
.Where(a => a.CompanyId == companyId && !a.IsDeleted
&& (a.AccountSubType == AccountSubType.Cash
|| a.AccountSubType == AccountSubType.Checking
|| a.AccountSubType == AccountSubType.Savings))
.SumAsync(a => (decimal?)a.OpeningBalance) ?? 0m;
var prePaymentsIn = await _context.Payments
.IgnoreQueryFilters()
.Where(p => p.CompanyId == companyId && !p.IsDeleted && p.PaymentDate < from)
.SumAsync(p => (decimal?)p.Amount) ?? 0m;
var preBillPaymentsOut = await _context.BillPayments
.IgnoreQueryFilters()
.Where(bp => bp.CompanyId == companyId && !bp.IsDeleted && bp.PaymentDate < from)
.SumAsync(bp => (decimal?)bp.Amount) ?? 0m;
var preExpensesOut = await _context.Expenses
.IgnoreQueryFilters()
.Where(e => e.CompanyId == companyId && !e.IsDeleted && e.Date < from)
.SumAsync(e => (decimal?)e.Amount) ?? 0m;
var beginningCash = cashAccountOpeningBalance + prePaymentsIn - preBillPaymentsOut - preExpensesOut;
return new CashFlowStatementDto
{
CompanyName = companyName,
From = from,
To = to,
Method = method,
CashFromCustomers = cashFromCustomers,
CashToVendors = cashToVendors,
CashForExpenses = cashForExpenses,
InvestingLines = investingLines,
FinancingLines = financingLines,
BeginningCash = beginningCash,
};
}
/// <summary>
/// Looks up the company name by ID for report headers and AI prompt injection.
/// Falls back to "Your Company" if the record is not found.
@@ -72,6 +72,45 @@ public class LedgerService : ILedgerService
LinkId = p.InvoiceId
});
// Customer deposits recorded to this account (DEBIT — cash received at deposit time)
var depositedDeposits = await _context.Deposits
.Where(d => d.DepositAccountId == accountId
&& d.ReceivedDate >= fromDate && d.ReceivedDate <= toDate)
.ToListAsync();
foreach (var d in depositedDeposits)
entries.Add(new LedgerEntryDto
{
Date = d.ReceivedDate,
Reference = d.ReceiptNumber,
Source = "Customer Deposit",
Description = d.Notes ?? d.Reference,
Debit = d.Amount,
Credit = 0,
LinkController = "Jobs",
LinkId = d.JobId
});
// Refunds paid FROM this account (CREDIT — cash leaves)
var refundsPaidFrom = await _context.Refunds
.Include(r => r.Invoice)
.Where(r => r.DepositAccountId == accountId
&& r.RefundDate >= fromDate && r.RefundDate <= toDate)
.ToListAsync();
foreach (var r in refundsPaidFrom)
entries.Add(new LedgerEntryDto
{
Date = r.RefundDate,
Reference = r.Reference ?? $"REF-{r.Id}",
Source = "Refund",
Description = r.Reason,
Debit = 0,
Credit = r.Amount,
LinkController = "Invoices",
LinkId = r.InvoiceId
});
// ── 2. Direct expenses paid FROM this account (CREDIT) ────────────────
// e.g. Checking account used to pay an expense
var expensesPaidFrom = await _context.Expenses
@@ -251,6 +290,46 @@ public class LedgerService : ILedgerService
LinkController = "Invoices",
LinkId = p.InvoiceId
});
// Credit memo applications reduce open AR (CREDIT)
var arCreditMemos = await _context.CreditMemoApplications
.Include(a => a.Invoice)
.Include(a => a.CreditMemo)
.Where(a => a.AppliedDate >= fromDate && a.AppliedDate <= toDate
&& a.Invoice.Status != InvoiceStatus.Voided)
.ToListAsync();
foreach (var cm in arCreditMemos)
entries.Add(new LedgerEntryDto
{
Date = cm.AppliedDate,
Reference = cm.CreditMemo?.MemoNumber ?? $"CM-{cm.Id}",
Source = "Credit Memo",
Description = $"Credit applied to {cm.Invoice?.InvoiceNumber}",
Debit = 0,
Credit = cm.AmountApplied,
LinkController = "Invoices",
LinkId = cm.InvoiceId
});
// Refunds re-open AR (DEBIT — customer owes again after refund)
var arRefunds = await _context.Refunds
.Include(r => r.Invoice)
.Where(r => r.RefundDate >= fromDate && r.RefundDate <= toDate && !r.IsDeleted)
.ToListAsync();
foreach (var r in arRefunds)
entries.Add(new LedgerEntryDto
{
Date = r.RefundDate,
Reference = r.Reference ?? $"REF-{r.Id}",
Source = "Refund",
Description = r.Reason,
Debit = r.Amount,
Credit = 0,
LinkController = "Invoices",
LinkId = r.InvoiceId
});
}
// ── 9. Accounts Payable ────────────────────────────────────────────────
@@ -296,6 +375,102 @@ public class LedgerService : ILedgerService
LinkController = "Bills",
LinkId = bp.BillId
});
// Vendor credit applications reduce AP (DEBIT — offset against what we owe)
var apVendorCredits = await _context.VendorCreditApplications
.Include(vca => vca.VendorCredit)
.Include(vca => vca.Bill)
.Where(vca => vca.VendorCredit.APAccountId == accountId
&& vca.AppliedDate >= fromDate && vca.AppliedDate <= toDate)
.ToListAsync();
foreach (var vca in apVendorCredits)
entries.Add(new LedgerEntryDto
{
Date = vca.AppliedDate,
Reference = vca.VendorCredit?.CreditNumber ?? $"VC-{vca.VendorCreditId}",
Source = "Vendor Credit",
Description = $"Credit applied to {vca.Bill?.BillNumber}",
Debit = vca.Amount,
Credit = 0,
LinkController = "VendorCredits",
LinkId = vca.VendorCreditId
});
}
// ── 11. Gift Certificate Liability (account 2500) ─────────────────────
// CR when GC is issued; DR when redeemed or voided with remaining balance.
if (account.AccountNumber == "2500")
{
var gcIssued = await _context.GiftCertificates
.Where(gc => !gc.IsDeleted && gc.IssueDate >= fromDate && gc.IssueDate <= toDate)
.ToListAsync();
foreach (var gc in gcIssued)
entries.Add(new LedgerEntryDto
{
Date = gc.IssueDate, Reference = gc.CertificateCode,
Source = "Gift Certificate", Description = "GC issued",
Debit = 0, Credit = gc.OriginalAmount,
LinkController = "GiftCertificates", LinkId = gc.Id
});
var gcRedemptions = await _context.GiftCertificateRedemptions
.Include(r => r.GiftCertificate)
.Where(r => !r.IsDeleted && r.RedeemedDate >= fromDate && r.RedeemedDate <= toDate)
.ToListAsync();
foreach (var r in gcRedemptions)
entries.Add(new LedgerEntryDto
{
Date = r.RedeemedDate, Reference = r.GiftCertificate?.CertificateCode ?? $"GC-{r.GiftCertificateId}",
Source = "GC Redemption", Description = "GC applied to invoice",
Debit = r.AmountRedeemed, Credit = 0,
LinkController = "GiftCertificates", LinkId = r.GiftCertificateId
});
var gcVoided = await _context.GiftCertificates
.Where(gc => !gc.IsDeleted && gc.Status == GiftCertificateStatus.Voided
&& gc.UpdatedAt >= fromDate && gc.UpdatedAt <= toDate
&& gc.OriginalAmount > gc.RedeemedAmount)
.ToListAsync();
foreach (var gc in gcVoided)
entries.Add(new LedgerEntryDto
{
Date = gc.UpdatedAt.GetValueOrDefault(), Reference = gc.CertificateCode,
Source = "GC Voided", Description = "Breakage income",
Debit = gc.OriginalAmount - gc.RedeemedAmount, Credit = 0,
LinkController = "GiftCertificates", LinkId = gc.Id
});
}
// ── 12. Customer Deposits liability (account 2300) ────────────────────
// CR when deposit is recorded; DR when deposit is applied to an invoice.
if (account.AccountNumber == "2300")
{
var depositsRecorded = await _context.Deposits
.Where(d => !d.IsDeleted && d.ReceivedDate >= fromDate && d.ReceivedDate <= toDate)
.ToListAsync();
foreach (var d in depositsRecorded)
entries.Add(new LedgerEntryDto
{
Date = d.ReceivedDate, Reference = d.ReceiptNumber,
Source = "Customer Deposit", Description = d.Notes ?? d.Reference,
Debit = 0, Credit = d.Amount,
LinkController = "Jobs", LinkId = d.JobId
});
var depositsApplied = await _context.Deposits
.Include(d => d.AppliedToInvoice)
.Where(d => !d.IsDeleted && d.AppliedToInvoiceId != null
&& d.AppliedDate >= fromDate && d.AppliedDate <= toDate)
.ToListAsync();
foreach (var d in depositsApplied)
entries.Add(new LedgerEntryDto
{
Date = d.AppliedDate!.Value, Reference = d.AppliedToInvoice?.InvoiceNumber ?? d.ReceiptNumber,
Source = "Deposit Applied", Description = $"Deposit {d.ReceiptNumber} applied to invoice",
Debit = d.Amount, Credit = 0,
LinkController = "Invoices", LinkId = d.AppliedToInvoiceId
});
}
// ── 10. Journal Entry lines touching this account ──────────────────
@@ -382,6 +557,16 @@ public class LedgerService : ILedgerService
.Where(p => p.DepositAccountId == accountId && p.PaymentDate < beforeDate)
.SumAsync(p => (decimal?)p.Amount) ?? 0;
// Customer deposits recorded to this account (DEBIT — cash received at deposit time)
debits += await _context.Deposits
.Where(d => !d.IsDeleted && d.DepositAccountId == accountId && d.ReceivedDate < beforeDate)
.SumAsync(d => (decimal?)d.Amount) ?? 0;
// Refunds paid FROM this account (CREDIT — cash leaves)
credits += await _context.Refunds
.Where(r => !r.IsDeleted && r.DepositAccountId == accountId && r.RefundDate < beforeDate)
.SumAsync(r => (decimal?)r.Amount) ?? 0;
// 2. Direct expenses paid FROM this account (CREDIT)
credits += await _context.Expenses
.Where(e => e.PaymentAccountId == accountId && e.Date < beforeDate)
@@ -434,6 +619,14 @@ public class LedgerService : ILedgerService
credits += await _context.Payments
.Where(p => p.PaymentDate < beforeDate)
.SumAsync(p => (decimal?)p.Amount) ?? 0;
credits += await _context.CreditMemoApplications
.Where(a => a.AppliedDate < beforeDate && a.Invoice.Status != InvoiceStatus.Voided)
.SumAsync(a => (decimal?)a.AmountApplied) ?? 0;
debits += await _context.Refunds
.Where(r => !r.IsDeleted && r.RefundDate < beforeDate)
.SumAsync(r => (decimal?)r.Amount) ?? 0;
}
// 9. Accounts Payable
@@ -449,6 +642,36 @@ public class LedgerService : ILedgerService
debits += await _context.BillPayments
.Where(bp => bp.Bill.APAccountId == accountId && bp.PaymentDate < beforeDate)
.SumAsync(bp => (decimal?)bp.Amount) ?? 0;
debits += await _context.VendorCreditApplications
.Where(vca => vca.VendorCredit.APAccountId == accountId && vca.AppliedDate < beforeDate)
.SumAsync(vca => (decimal?)vca.Amount) ?? 0;
}
// 11. GC Liability (account 2500)
if (account.AccountNumber == "2500")
{
credits += await _context.GiftCertificates
.Where(gc => !gc.IsDeleted && gc.IssueDate < beforeDate)
.SumAsync(gc => (decimal?)gc.OriginalAmount) ?? 0;
debits += await _context.GiftCertificateRedemptions
.Where(r => !r.IsDeleted && r.RedeemedDate < beforeDate)
.SumAsync(r => (decimal?)r.AmountRedeemed) ?? 0;
debits += await _context.GiftCertificates
.Where(gc => !gc.IsDeleted && gc.Status == GiftCertificateStatus.Voided
&& gc.UpdatedAt < beforeDate && gc.OriginalAmount > gc.RedeemedAmount)
.SumAsync(gc => (decimal?)(gc.OriginalAmount - gc.RedeemedAmount)) ?? 0;
}
// 12. Customer Deposits liability (account 2300)
if (account.AccountNumber == "2300")
{
credits += await _context.Deposits
.Where(d => !d.IsDeleted && d.ReceivedDate < beforeDate)
.SumAsync(d => (decimal?)d.Amount) ?? 0;
debits += await _context.Deposits
.Where(d => !d.IsDeleted && d.AppliedToInvoiceId != null && d.AppliedDate < beforeDate)
.SumAsync(d => (decimal?)d.Amount) ?? 0;
}
// 10. Posted journal entry lines touching this account (prior to period)
@@ -621,7 +621,7 @@ public class NotificationService : INotificationService
/// (the <paramref name="paymentUrl"/> parameter). Without a payment URL the email is a
/// standard "here is your invoice" message with no payment CTA.
/// </summary>
public async Task NotifyInvoiceSentAsync(Invoice invoice, byte[]? pdfAttachment = null, string? pdfFilename = null, string? paymentUrl = null, string? overrideEmail = null)
public async Task NotifyInvoiceSentAsync(Invoice invoice, byte[]? pdfAttachment = null, string? pdfFilename = null, string? paymentUrl = null, string? overrideEmail = null, bool sendSms = false, string? viewUrl = null)
{
try
{
@@ -705,6 +705,50 @@ public class NotificationService : INotificationService
await WriteLog(SkippedLog(NotificationChannel.Email, NotificationType.InvoiceSent,
customerName, string.Join(", ", invoiceEmails), invoice.CompanyId, customerId: customer.Id, invoiceId: invoice.Id));
}
// SMS — only when explicitly requested by staff (sendSms=true), customer has opted in,
// and the company's SMS is active. Uses viewUrl (permanent) so customer can see the full
// invoice; paymentUrl (expiring Stripe link) is surfaced on the view page itself.
if (sendSms)
{
var smsAllowed = await IsSmsAllowedForCompanyAsync(company);
var smsPhone = customer.MobilePhone ?? customer.Phone;
if (smsAllowed && customer.NotifyBySms && !string.IsNullOrWhiteSpace(smsPhone))
{
var urlForSms = viewUrl ?? paymentUrl ?? string.Empty;
var values = new Dictionary<string, string>
{
["companyName"] = companyName,
["invoiceNumber"] = invoice.InvoiceNumber,
["invoiceTotal"] = invoice.Total.ToString("C"),
["viewUrl"] = urlForSms
};
var message = await GetRenderedSmsAsync(invoice.CompanyId, NotificationType.InvoiceSent, values,
$"{companyName}: Invoice {invoice.InvoiceNumber} for {invoice.Total:C} is ready. View your invoice: {urlForSms} Reply STOP to opt out.");
var (smsSent, smsError) = await _smsService.SendSmsAsync(smsPhone, message);
await WriteLog(new NotificationLog
{
Channel = NotificationChannel.Sms,
NotificationType = NotificationType.InvoiceSent,
Status = smsSent ? NotificationStatus.Sent : NotificationStatus.Failed,
RecipientName = customerName,
Recipient = smsPhone,
Message = message,
ErrorMessage = smsError,
SentAt = DateTime.UtcNow,
CustomerId = customer.Id,
InvoiceId = invoice.Id,
CompanyId = invoice.CompanyId
});
}
else if (!string.IsNullOrWhiteSpace(smsPhone))
{
await WriteLog(SkippedLog(NotificationChannel.Sms, NotificationType.InvoiceSent,
customerName, smsPhone, invoice.CompanyId, customerId: customer.Id, invoiceId: invoice.Id));
}
}
}
catch (Exception ex)
{
@@ -1153,6 +1197,10 @@ public class NotificationService : INotificationService
"Invoice {{invoiceNumber}} from {{companyName}}",
"<p>Dear {{customerName}},</p><p>Please find your invoice <strong>{{invoiceNumber}}</strong> for <strong>{{invoiceTotal}}</strong> attached.{{invoiceDueDate}}</p><p>Thank you for your business with {{companyName}}.</p>"
),
[(NotificationType.InvoiceSent, NotificationChannel.Sms)] = (
null,
"{{companyName}}: Invoice {{invoiceNumber}} for {{invoiceTotal}} is ready. View your invoice: {{viewUrl}} Reply STOP to opt out."
),
[(NotificationType.PaymentReceived, NotificationChannel.Email)] = (
"Payment Received — Invoice {{invoiceNumber}}",
"<p>Dear {{customerName}},</p><p>We have received your payment of <strong>{{paymentAmount}}</strong> on {{paymentDate}} for invoice <strong>{{invoiceNumber}}</strong>.{{balanceDue}}</p><p>Thank you for your business with {{companyName}}.</p>"
@@ -70,6 +70,10 @@ public partial class SeedDataService
new Account { AccountNumber = "4100", Name = "Sandblasting Revenue", AccountType = AccountType.Revenue, AccountSubType = AccountSubType.ServiceRevenue, IsSystem = false, IsActive = true, Description = "Revenue from sandblasting services", CompanyId = company.Id, CreatedAt = now },
new Account { AccountNumber = "4200", Name = "Other Service Revenue", AccountType = AccountType.Revenue, AccountSubType = AccountSubType.ServiceRevenue, IsSystem = false, IsActive = true, Description = "Revenue from other shop services", CompanyId = company.Id, CreatedAt = now },
new Account { AccountNumber = "4900", Name = "Other Income", AccountType = AccountType.Revenue, AccountSubType = AccountSubType.OtherIncome, IsSystem = false, IsActive = true, Description = "Miscellaneous income", CompanyId = company.Id, CreatedAt = now },
// Contra-revenue: debited when invoice discounts are applied so the GL balances.
// A credit-normal account with a debit balance appears in the Trial Balance debit column,
// reducing net revenue to match the discounted AR amount that was posted.
new Account { AccountNumber = "4950", Name = "Sales Discounts", AccountType = AccountType.Revenue, AccountSubType = AccountSubType.OtherIncome, IsSystem = true, IsActive = true, Description = "Contra-revenue for invoice discounts granted to customers", CompanyId = company.Id, CreatedAt = now },
// ── COST OF GOODS SOLD ────────────────────────────────────────────
new Account { AccountNumber = "5000", Name = "Cost of Goods Sold", AccountType = AccountType.CostOfGoods, AccountSubType = AccountSubType.CostOfGoodsSold, IsSystem = false, IsActive = true, Description = "Direct cost of services delivered", CompanyId = company.Id, CreatedAt = now },
@@ -96,4 +100,44 @@ public partial class SeedDataService
return accounts.Count;
}
/// <summary>
/// Ensures system accounts introduced after the initial chart-of-accounts seed exist for the
/// given company. Idempotent: each account is only inserted when absent, so this is safe to
/// call repeatedly from the "Seed Lookup Tables" flow.
/// Call this after <see cref="SeedDefaultChartOfAccountsAsync"/> so that newly onboarded
/// companies get all accounts in one pass while existing companies receive only the missing ones.
/// </summary>
/// <returns>Number of accounts inserted (0 if all are already present).</returns>
private async Task<int> EnsureSystemAccountsAsync(Company company)
{
var now = DateTime.UtcNow;
int added = 0;
// 4950 Sales Discounts — contra-revenue account introduced to balance the GL when
// invoice discounts are applied (DR Sales Discounts / CR Revenue gap fixed).
var has4950 = await _context.Set<Account>()
.IgnoreQueryFilters()
.AnyAsync(a => a.CompanyId == company.Id && a.AccountNumber == "4950" && !a.IsDeleted);
if (!has4950)
{
_context.Set<Account>().Add(new Account
{
AccountNumber = "4950",
Name = "Sales Discounts",
AccountType = AccountType.Revenue,
AccountSubType = AccountSubType.OtherIncome,
IsSystem = true,
IsActive = true,
Description = "Contra-revenue for invoice discounts granted to customers",
CompanyId = company.Id,
CreatedAt = now
});
await _context.SaveChangesAsync();
added++;
}
return added;
}
}
@@ -283,6 +283,14 @@ public partial class SeedDataService : ISeedDataService
result.ItemsSeeded += accountsSeeded;
}
// Backfill any system accounts added after the initial seed (idempotent).
var systemAccountsAdded = await EnsureSystemAccountsAsync(company);
if (systemAccountsAdded > 0)
{
details.Add($"✓ {systemAccountsAdded} missing system account(s) added");
result.ItemsSeeded += systemAccountsAdded;
}
result.Message = $"Lookup tables initialized for {company.CompanyName}";
result.Details = details;
}
@@ -31,6 +31,7 @@ public static class AppConstants
{
public const string CompanyAdmin = "CompanyAdmin";
public const string Manager = "Manager";
public const string Accountant = "Accountant";
public const string Worker = "Worker";
public const string Viewer = "Viewer";
}
@@ -58,6 +59,8 @@ public static class AppConstants
public const string CanManageMaintenance = "CanManageMaintenance";
public const string CanManageInvoices = "CanManageInvoices";
public const string CanViewReports = "CanViewReports";
public const string CanManageBills = "CanManageBills";
public const string CanManageAccounting = "CanManageAccounting";
}
public static class FileUpload
@@ -103,6 +106,10 @@ public static class AppConstants
public const string FinancialSummary = "FinancialSummary";
public const string CashFlowForecast = "CashFlowForecast";
public const string AnomalyDetection = "AnomalyDetection";
public const string BankRecAutoMatch = "BankRecAutoMatch";
public const string LatePaymentPrediction = "LatePaymentPrediction";
public const string FinancialQuery = "FinancialQuery";
public const string RecurringBillDetection = "RecurringBillDetection";
}
public static class Legal
@@ -0,0 +1,319 @@
using Microsoft.EntityFrameworkCore;
using PowderCoating.Core.Entities;
using PowderCoating.Core.Enums;
using PowderCoating.Infrastructure.Data;
using System.Text.Json;
namespace PowderCoating.Web.BackgroundServices;
/// <summary>
/// Singleton background service that wakes hourly and generates bills or expenses for any
/// <see cref="RecurringTemplate"/> whose <c>NextFireDate</c> is today or in the past.
/// Bills are created as Draft so users can review; Expenses are recorded immediately.
/// NextFireDate is advanced after each successful fire. Templates are deactivated automatically
/// when <c>MaxOccurrences</c> is reached or <c>EndDate</c> has passed.
/// </summary>
public class RecurringTransactionService : BackgroundService
{
private readonly IServiceScopeFactory _scopeFactory;
private readonly ILogger<RecurringTransactionService> _logger;
public RecurringTransactionService(
IServiceScopeFactory scopeFactory,
ILogger<RecurringTransactionService> logger)
{
_scopeFactory = scopeFactory;
_logger = logger;
}
/// <summary>
/// Loops forever, sleeping one hour between passes.
/// Uses <see cref="IServiceScopeFactory"/> to resolve scoped services (DbContext) from the
/// singleton because BackgroundService lives for the application lifetime.
/// </summary>
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
_logger.LogInformation("RecurringTransactionService started.");
while (!stoppingToken.IsCancellationRequested)
{
try
{
await RunAsync(stoppingToken);
}
catch (Exception ex)
{
_logger.LogError(ex, "RecurringTransactionService run failed.");
}
try
{
await Task.Delay(TimeSpan.FromHours(1), stoppingToken);
}
catch (OperationCanceledException)
{
break;
}
}
_logger.LogInformation("RecurringTransactionService stopped.");
}
/// <summary>
/// Loads all active templates whose NextFireDate is on or before today and fires each one.
/// Uses IgnoreQueryFilters to bypass the HTTP-context-dependent tenant filter.
/// </summary>
private async Task RunAsync(CancellationToken ct)
{
using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
var today = DateTime.UtcNow.Date;
var due = await db.RecurringTemplates
.IgnoreQueryFilters()
.Where(t => !t.IsDeleted && t.IsActive && t.NextFireDate.Date <= today)
.ToListAsync(ct);
if (due.Count == 0) return;
_logger.LogInformation("RecurringTransactionService: {Count} template(s) due.", due.Count);
foreach (var template in due)
{
if (ct.IsCancellationRequested) break;
await FireTemplateAsync(db, template, ct);
}
}
/// <summary>
/// Fires a single template: creates the document, updates OccurrenceCount + NextFireDate,
/// and deactivates the template when limits are reached. Errors are captured in LastError
/// so the service loop continues to process other templates.
/// </summary>
private async Task FireTemplateAsync(
ApplicationDbContext db,
RecurringTemplate template,
CancellationToken ct)
{
try
{
if (template.TemplateType == RecurringTemplateType.Bill)
await CreateBillAsync(db, template, ct);
else
await CreateExpenseAsync(db, template, ct);
template.OccurrenceCount++;
template.NextFireDate = AdvanceDate(template.NextFireDate, template.Frequency, template.IntervalCount);
template.LastError = null;
// Deactivate when limits reached
if (template.MaxOccurrences.HasValue && template.OccurrenceCount >= template.MaxOccurrences.Value)
{
template.IsActive = false;
_logger.LogInformation("Template {Id} ({Name}) deactivated: MaxOccurrences reached.", template.Id, template.Name);
}
else if (template.EndDate.HasValue && template.NextFireDate.Date > template.EndDate.Value.Date)
{
template.IsActive = false;
_logger.LogInformation("Template {Id} ({Name}) deactivated: EndDate passed.", template.Id, template.Name);
}
await db.SaveChangesAsync(ct);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to fire recurring template {Id} ({Name}).", template.Id, template.Name);
template.LastError = ex.Message;
try { await db.SaveChangesAsync(ct); } catch { /* best-effort */ }
}
}
// -------------------------------------------------------------------------
// Bill creation
// -------------------------------------------------------------------------
/// <summary>
/// Deserializes the template's JSON payload and inserts a Draft <see cref="Bill"/> with
/// its line items. GL posting is deferred — the user posts the Draft bill manually after review.
/// </summary>
private async Task CreateBillAsync(ApplicationDbContext db, RecurringTemplate template, CancellationToken ct)
{
var data = JsonSerializer.Deserialize<BillTemplateData>(template.TemplateData)
?? throw new InvalidOperationException("Invalid bill template data.");
var bill = new Bill
{
BillNumber = await NextBillNumberAsync(db, ct),
VendorId = data.VendorId,
APAccountId = data.APAccountId,
BillDate = DateTime.UtcNow,
DueDate = data.Terms != null ? ParseDueDate(data.Terms) : null,
Status = BillStatus.Draft,
Terms = data.Terms,
Memo = $"[Recurring] {data.Memo}".Trim(),
SubTotal = data.LineItems?.Sum(l => l.Quantity * l.UnitPrice) ?? 0,
TaxPercent = data.TaxPercent,
TaxAmount = 0,
Total = 0,
CompanyId = template.CompanyId,
CreatedBy = "Recurring",
CreatedAt = DateTime.UtcNow
};
bill.TaxAmount = Math.Round(bill.SubTotal * bill.TaxPercent / 100, 2);
bill.Total = bill.SubTotal + bill.TaxAmount;
db.Bills.Add(bill);
await db.SaveChangesAsync(ct); // get bill.Id
int order = 1;
foreach (var line in data.LineItems ?? [])
{
db.BillLineItems.Add(new BillLineItem
{
BillId = bill.Id,
AccountId = line.AccountId,
Description = line.Description,
Quantity = line.Quantity,
UnitPrice = line.UnitPrice,
Amount = Math.Round(line.Quantity * line.UnitPrice, 2),
DisplayOrder = order++,
CompanyId = template.CompanyId,
CreatedAt = DateTime.UtcNow
});
}
_logger.LogInformation("Recurring bill {BillNumber} created for template {Id}.", bill.BillNumber, template.Id);
}
// -------------------------------------------------------------------------
// Expense creation
// -------------------------------------------------------------------------
/// <summary>
/// Deserializes the template's JSON payload and inserts an <see cref="Expense"/> immediately.
/// Expenses are already-paid transactions so no user review is required.
/// </summary>
private async Task CreateExpenseAsync(ApplicationDbContext db, RecurringTemplate template, CancellationToken ct)
{
var data = JsonSerializer.Deserialize<ExpenseTemplateData>(template.TemplateData)
?? throw new InvalidOperationException("Invalid expense template data.");
var expense = new Expense
{
ExpenseNumber = await NextExpenseNumberAsync(db, ct),
Date = DateTime.UtcNow,
VendorId = data.VendorId == 0 ? null : data.VendorId,
ExpenseAccountId = data.ExpenseAccountId,
PaymentAccountId = data.PaymentAccountId,
PaymentMethod = (PaymentMethod)data.PaymentMethod,
Amount = data.Amount,
Memo = $"[Recurring] {data.Memo}".Trim(),
CompanyId = template.CompanyId,
CreatedBy = "Recurring",
CreatedAt = DateTime.UtcNow
};
db.Expenses.Add(expense);
_logger.LogInformation("Recurring expense {ExpenseNumber} created for template {Id}.", expense.ExpenseNumber, template.Id);
}
// -------------------------------------------------------------------------
// Helpers
// -------------------------------------------------------------------------
/// <summary>Advances a date by one period (Frequency × IntervalCount).</summary>
private static DateTime AdvanceDate(DateTime date, RecurringFrequency freq, int interval)
{
return freq switch
{
RecurringFrequency.Daily => date.AddDays(interval),
RecurringFrequency.Weekly => date.AddDays(7 * interval),
RecurringFrequency.BiWeekly => date.AddDays(14 * interval),
RecurringFrequency.Monthly => date.AddMonths(interval),
RecurringFrequency.Quarterly => date.AddMonths(3 * interval),
RecurringFrequency.Annually => date.AddYears(interval),
_ => date.AddMonths(interval)
};
}
/// <summary>
/// Generates the next sequential bill number (BILL-YYMM-####).
/// Uses IgnoreQueryFilters so soft-deleted bills are included in the sequence scan.
/// </summary>
private static async Task<string> NextBillNumberAsync(ApplicationDbContext db, CancellationToken ct)
{
var prefix = $"BILL-{DateTime.Now:yyMM}-";
var last = await db.Bills
.IgnoreQueryFilters()
.Where(b => b.BillNumber.StartsWith(prefix))
.OrderByDescending(b => b.BillNumber)
.Select(b => b.BillNumber)
.FirstOrDefaultAsync(ct);
int next = 1;
if (last != null && int.TryParse(last[prefix.Length..], out int n)) next = n + 1;
return $"{prefix}{next:D4}";
}
/// <summary>
/// Generates the next sequential expense number (EXP-YYMM-####).
/// Uses IgnoreQueryFilters so soft-deleted expenses are included in the sequence scan.
/// </summary>
private static async Task<string> NextExpenseNumberAsync(ApplicationDbContext db, CancellationToken ct)
{
var prefix = $"EXP-{DateTime.Now:yyMM}-";
var last = await db.Expenses
.IgnoreQueryFilters()
.Where(e => e.ExpenseNumber.StartsWith(prefix))
.OrderByDescending(e => e.ExpenseNumber)
.Select(e => e.ExpenseNumber)
.FirstOrDefaultAsync(ct);
int next = 1;
if (last != null && int.TryParse(last[prefix.Length..], out int n)) next = n + 1;
return $"{prefix}{next:D4}";
}
/// <summary>Best-effort due date from a payment terms string (delegates to the same patterns as PaymentTermsParser).</summary>
private static DateTime? ParseDueDate(string terms)
{
var t = terms.Trim().ToUpperInvariant();
if (t is "DUE ON RECEIPT" or "COD" or "IMMEDIATE") return DateTime.UtcNow.Date;
// "Net 30", "NET30", "2/10 Net 30" → extract trailing number
var parts = t.Split(' ', StringSplitOptions.RemoveEmptyEntries);
var last = parts.LastOrDefault();
if (last != null && int.TryParse(last, out int days) && days > 0)
return DateTime.UtcNow.Date.AddDays(days);
return null;
}
// -------------------------------------------------------------------------
// JSON payload records (must match RecurringTemplatesController serialization)
// -------------------------------------------------------------------------
internal sealed record BillTemplateData(
int VendorId,
int APAccountId,
string? Terms,
string? Memo,
decimal TaxPercent,
List<BillLineData>? LineItems);
internal sealed record BillLineData(
int? AccountId,
string Description,
decimal Quantity,
decimal UnitPrice);
internal sealed record ExpenseTemplateData(
int VendorId,
int ExpenseAccountId,
int PaymentAccountId,
int PaymentMethod,
decimal Amount,
string? Memo);
}
@@ -427,6 +427,186 @@ public class AccountsController : Controller
return View(ledger);
}
// ── Year-End Close ────────────────────────────────────────────────────────
/// <summary>
/// GET: landing page showing close history and a form to initiate the current year close.
/// Companyid is resolved from tenant context; year defaults to the prior fiscal year
/// (the most common use case — close last year after final entries are posted).
/// </summary>
[HttpGet]
[Authorize(Policy = AppConstants.Policies.CompanyAdminOnly)]
public async Task<IActionResult> YearEndClose()
{
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var history = (await _unitOfWork.YearEndCloses.FindAsync(y => true, false, y => y.JournalEntry))
.OrderByDescending(y => y.ClosedYear)
.ToList();
ViewBag.History = history;
ViewBag.SuggestedYear = DateTime.Now.Year - 1;
ViewBag.ClosedYears = history.Select(y => y.ClosedYear).ToHashSet();
return View();
}
/// <summary>
/// POST: executes the year-end close for the specified fiscal year.
/// Sums all Revenue account balances (credit-normal) and all Expense/COGS balances
/// (debit-normal), computes net income, posts a JE that zeroes them into Retained
/// Earnings, then records a YearEndClose audit entry. Idempotency: a year that has
/// already been closed is rejected.
/// </summary>
[HttpPost, ValidateAntiForgeryToken]
[Authorize(Policy = AppConstants.Policies.CompanyAdminOnly)]
public async Task<IActionResult> CloseYear(int year)
{
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
// Idempotency check
var existing = (await _unitOfWork.YearEndCloses.FindAsync(y => y.ClosedYear == year)).FirstOrDefault();
if (existing != null)
{
TempData["Error"] = $"{year} has already been closed (JE {existing.JournalEntryId}).";
return RedirectToAction(nameof(YearEndClose));
}
// Load all active accounts with balances
var accounts = (await _unitOfWork.Accounts.FindAsync(a => a.IsActive)).ToList();
var revenueAccounts = accounts.Where(a => a.AccountType == AccountType.Revenue).ToList();
var expenseAccounts = accounts.Where(a =>
a.AccountType == AccountType.Expense ||
a.AccountSubType == AccountSubType.CostOfGoodsSold).ToList();
// Find or locate the Retained Earnings account
var retainedEarnings = accounts.FirstOrDefault(a =>
a.AccountSubType == AccountSubType.RetainedEarnings);
if (retainedEarnings == null)
{
TempData["Error"] = "No Retained Earnings account found. Create an Equity account with the 'Retained Earnings' sub-type first.";
return RedirectToAction(nameof(YearEndClose));
}
// Net income = total revenue credits total expense debits
var totalRevenue = revenueAccounts.Sum(a => a.CurrentBalance);
var totalExpenses = expenseAccounts.Sum(a => a.CurrentBalance);
var netIncome = totalRevenue - totalExpenses;
if (totalRevenue == 0 && totalExpenses == 0)
{
TempData["Error"] = $"No revenue or expense balances found for {year}. Nothing to close.";
return RedirectToAction(nameof(YearEndClose));
}
int newJeId = 0;
await _unitOfWork.ExecuteInTransactionAsync(async () =>
{
var lines = new List<JournalEntryLine>();
// Zero out Revenue accounts: DR each revenue account (reduces credit balance to 0)
foreach (var acct in revenueAccounts.Where(a => a.CurrentBalance != 0))
{
lines.Add(new JournalEntryLine
{
AccountId = acct.Id,
DebitAmount = acct.CurrentBalance > 0 ? acct.CurrentBalance : 0,
CreditAmount = acct.CurrentBalance < 0 ? Math.Abs(acct.CurrentBalance) : 0,
Description = $"Close {year} — {acct.Name}",
CompanyId = companyId, CreatedAt = DateTime.UtcNow
});
await _accountBalanceService.DebitAsync(acct.Id, acct.CurrentBalance > 0 ? acct.CurrentBalance : 0);
if (acct.CurrentBalance < 0)
await _accountBalanceService.CreditAsync(acct.Id, Math.Abs(acct.CurrentBalance));
}
// Zero out Expense/COGS accounts: CR each expense account (reduces debit balance to 0)
foreach (var acct in expenseAccounts.Where(a => a.CurrentBalance != 0))
{
lines.Add(new JournalEntryLine
{
AccountId = acct.Id,
DebitAmount = acct.CurrentBalance < 0 ? Math.Abs(acct.CurrentBalance) : 0,
CreditAmount = acct.CurrentBalance > 0 ? acct.CurrentBalance : 0,
Description = $"Close {year} — {acct.Name}",
CompanyId = companyId, CreatedAt = DateTime.UtcNow
});
await _accountBalanceService.CreditAsync(acct.Id, acct.CurrentBalance > 0 ? acct.CurrentBalance : 0);
if (acct.CurrentBalance < 0)
await _accountBalanceService.DebitAsync(acct.Id, Math.Abs(acct.CurrentBalance));
}
// Plug the net into Retained Earnings: CR if profit, DR if loss
if (netIncome > 0)
{
lines.Add(new JournalEntryLine
{
AccountId = retainedEarnings.Id,
CreditAmount = netIncome,
Description = $"Net income {year} → Retained Earnings",
CompanyId = companyId, CreatedAt = DateTime.UtcNow
});
await _accountBalanceService.CreditAsync(retainedEarnings.Id, netIncome);
}
else if (netIncome < 0)
{
lines.Add(new JournalEntryLine
{
AccountId = retainedEarnings.Id,
DebitAmount = Math.Abs(netIncome),
Description = $"Net loss {year} → Retained Earnings",
CompanyId = companyId, CreatedAt = DateTime.UtcNow
});
await _accountBalanceService.DebitAsync(retainedEarnings.Id, Math.Abs(netIncome));
}
// Post the JE
var prefix = $"JE-{year % 100:D2}12-";
var existing2 = await _unitOfWork.JournalEntries.FindAsync(
je => je.CompanyId == companyId && je.EntryNumber.StartsWith(prefix),
ignoreQueryFilters: true);
int next = existing2.Any()
? existing2.Select(je => je.EntryNumber[prefix.Length..]).Select(s => int.TryParse(s, out int n) ? n : 0).Max() + 1
: 1;
var je = new JournalEntry
{
EntryNumber = $"{prefix}{next:D4}",
EntryDate = new DateTime(year, 12, 31, 0, 0, 0, DateTimeKind.Utc),
Description = $"Year-end close — {year}",
Reference = $"CLOSE-{year}",
Status = JournalEntryStatus.Posted,
PostedBy = User.Identity?.Name,
PostedAt = DateTime.UtcNow,
CompanyId = companyId,
CreatedAt = DateTime.UtcNow,
Lines = lines
};
await _unitOfWork.JournalEntries.AddAsync(je);
await _unitOfWork.CompleteAsync();
// Record the close
var close = new YearEndClose
{
ClosedYear = year,
ClosedAt = DateTime.UtcNow,
ClosedBy = User.Identity?.Name,
JournalEntryId = je.Id,
CompanyId = companyId,
CreatedAt = DateTime.UtcNow
};
await _unitOfWork.YearEndCloses.AddAsync(close);
await _unitOfWork.CompleteAsync();
newJeId = je.Id;
});
TempData["Success"] = $"Year {year} closed. Net income {netIncome:C} transferred to Retained Earnings. " +
$"See Journal Entry for details.";
return RedirectToAction(nameof(YearEndClose));
}
// ── Helpers ──────────────────────────────────────────────────────────────
/// <summary>
@@ -2,6 +2,7 @@ using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Rendering;
using Microsoft.EntityFrameworkCore;
using PowderCoating.Application.DTOs.AI;
using PowderCoating.Application.Interfaces;
using PowderCoating.Core.Entities;
using PowderCoating.Core.Enums;
@@ -15,13 +16,19 @@ public class BankReconciliationsController : Controller
{
private readonly IUnitOfWork _unitOfWork;
private readonly ITenantContext _tenantContext;
private readonly IAccountingAiService _accountingAi;
private readonly IAiUsageLogger _usageLogger;
public BankReconciliationsController(
IUnitOfWork unitOfWork,
ITenantContext tenantContext)
ITenantContext tenantContext,
IAccountingAiService accountingAi,
IAiUsageLogger usageLogger)
{
_unitOfWork = unitOfWork;
_tenantContext = tenantContext;
_accountingAi = accountingAi;
_usageLogger = usageLogger;
}
private bool AllowAccounting() =>
@@ -49,7 +56,7 @@ public class BankReconciliationsController : Controller
// ── Create ───────────────────────────────────────────────────────────────
[Authorize(Policy = AppConstants.Policies.CanManageJobs)]
[Authorize(Policy = AppConstants.Policies.CanManageAccounting)]
public async Task<IActionResult> Create()
{
if (!AllowAccounting()) return RedirectToAction("Landing", "Reports");
@@ -58,7 +65,7 @@ public class BankReconciliationsController : Controller
}
[HttpPost]
[Authorize(Policy = AppConstants.Policies.CanManageJobs)]
[Authorize(Policy = AppConstants.Policies.CanManageAccounting)]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Create(BankReconciliation model)
{
@@ -164,7 +171,7 @@ public class BankReconciliationsController : Controller
/// Returns updated running totals as JSON.
/// </summary>
[HttpPost]
[Authorize(Policy = AppConstants.Policies.CanManageJobs)]
[Authorize(Policy = AppConstants.Policies.CanManageAccounting)]
[ValidateAntiForgeryToken]
public async Task<IActionResult> ToggleCleared(
int reconId, string entityType, int entityId, bool isCleared)
@@ -200,7 +207,7 @@ public class BankReconciliationsController : Controller
/// <summary>Completes the reconciliation. Only allowed when Difference == 0.00.</summary>
[HttpPost]
[Authorize(Policy = AppConstants.Policies.CanManageJobs)]
[Authorize(Policy = AppConstants.Policies.CanManageAccounting)]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Complete(int id, decimal difference)
{
@@ -269,6 +276,91 @@ public class BankReconciliationsController : Controller
return View(recon);
}
// ── AI Auto-Match (AJAX) ──────────────────────────────────────────────────
/// <summary>
/// AJAX endpoint. Passes uncleared bank rec items to Claude and returns suggested items
/// to mark as cleared. The controller assembles all three transaction types (deposits,
/// bill payments, expenses) for the reconciliation's account, then delegates scoring to
/// <see cref="IAccountingAiService.AutoMatchReconciliationAsync"/>. The caller applies
/// suggestions client-side by auto-checking the corresponding table rows.
/// </summary>
[HttpPost]
[Authorize(Policy = AppConstants.Policies.CanManageAccounting)]
[ValidateAntiForgeryToken]
public async Task<IActionResult> AiSuggestMatches(int reconId)
{
if (!AllowAccounting()) return Forbid();
var recon = (await _unitOfWork.BankReconciliations.FindAsync(
br => br.Id == reconId, false, br => br.Account))
.FirstOrDefault();
if (recon == null) return NotFound();
var accountId = recon.AccountId;
var statementDate = recon.StatementDate;
var items = new List<BankRecMatchItem>();
(await _unitOfWork.Payments.FindAsync(
p => p.DepositAccountId == accountId && p.PaymentDate <= statementDate && !p.IsCleared))
.ToList()
.ForEach(p => items.Add(new BankRecMatchItem
{
EntityType = "Payment",
EntityId = p.Id,
Date = p.PaymentDate.ToString("yyyy-MM-dd"),
Reference = p.Reference ?? $"PMT-{p.Id}",
Description = $"Payment #{p.InvoiceId}",
Amount = p.Amount,
Direction = "deposit"
}));
(await _unitOfWork.BillPayments.FindAsync(
bp => bp.BankAccountId == accountId && bp.PaymentDate <= statementDate && !bp.IsCleared))
.ToList()
.ForEach(bp => items.Add(new BankRecMatchItem
{
EntityType = "BillPayment",
EntityId = bp.Id,
Date = bp.PaymentDate.ToString("yyyy-MM-dd"),
Reference = bp.PaymentNumber,
Description = bp.Memo ?? bp.BillId.ToString(),
Amount = bp.Amount,
Direction = "payment"
}));
(await _unitOfWork.Expenses.FindAsync(
e => e.PaymentAccountId == accountId && e.Date <= statementDate && !e.IsCleared))
.ToList()
.ForEach(e => items.Add(new BankRecMatchItem
{
EntityType = "Expense",
EntityId = e.Id,
Date = e.Date.ToString("yyyy-MM-dd"),
Reference = e.ExpenseNumber,
Description = e.Memo ?? string.Empty,
Amount = e.Amount,
Direction = "payment"
}));
if (!items.Any())
return Json(new { success = false, errorMessage = "No uncleared transactions to analyze." });
var request = new AutoMatchRequest
{
UnclearedItems = items,
BeginningBalance = recon.BeginningBalance,
StatementEndingBalance = recon.EndingBalance
};
var result = await _accountingAi.AutoMatchReconciliationAsync(request);
var userId = User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value ?? "";
await _usageLogger.LogAsync(recon.CompanyId, userId, AppConstants.AiFeatures.BankRecAutoMatch, result.Success);
return Json(result);
}
// ── Helpers ──────────────────────────────────────────────────────────────
private async Task PopulateAccountDropdownAsync()
@@ -1,4 +1,4 @@
using AutoMapper;
using AutoMapper;
using Microsoft.AspNetCore.Authorization;
using PowderCoating.Shared.Constants;
using Microsoft.AspNetCore.Identity;
@@ -58,13 +58,13 @@ public class BillsController : Controller
_usageLogger = usageLogger;
}
// ── Index ────────────────────────────────────────────────────────────────
// -- Index ----------------------------------------------------------------
/// <summary>
/// Lists bills and direct expenses in a unified AP ledger view. The <paramref name="type"/>
/// parameter lets the caller pin the list to Bills only, Expenses only, or both (null).
/// Expenses are inherently fully paid so they are always excluded when the caller filters to
/// "Unpaid" or "Overdue" — preventing them from inflating the "amount owed" summary.
/// "Unpaid" or "Overdue" — preventing them from inflating the "amount owed" summary.
/// Amount-based search strips leading $ and commas before comparing so "$1,234" works naturally.
/// </summary>
public async Task<IActionResult> Index(string? type, string? search, string? status, int page = 1, int pageSize = 25)
@@ -112,7 +112,7 @@ public class BillsController : Controller
}));
}
// Expenses are always fully paid — exclude when filtering to unpaid/overdue bills only
// Expenses are always fully paid — exclude when filtering to unpaid/overdue bills only
if ((type == null || type == "Expense") && status != "Unpaid" && status != "Overdue")
{
var expSearch = search;
@@ -160,13 +160,13 @@ public class BillsController : Controller
return View(pagedEntries);
}
// ── Create ───────────────────────────────────────────────────────────────
// -- Create ---------------------------------------------------------------
// ── Create from Purchase Order ────────────────────────────────────────────
// -- Create from Purchase Order --------------------------------------------
/// <summary>
/// Scaffolds a new bill pre-filled from a received purchase order. Only POs in
/// <c>Received</c> or <c>PartiallyReceived</c> status can be billed — earlier states mean
/// <c>Received</c> or <c>PartiallyReceived</c> status can be billed — earlier states mean
/// goods have not yet arrived and no liability has been incurred. If a bill already exists for
/// the PO the user is redirected to the existing bill to prevent duplicate AP entries.
/// Line items are copied from PO items (using inventory item names where available), and
@@ -174,7 +174,7 @@ public class BillsController : Controller
/// <c>DefaultExpenseAccountId</c> is used to pre-categorise all lines, falling back to the
/// first active Expense/COGS account when the vendor has no default configured.
/// </summary>
[Authorize(Policy = AppConstants.Policies.CanManageInventory)]
[Authorize(Policy = AppConstants.Policies.CanManageBills)]
public async Task<IActionResult> CreateFromPurchaseOrder(int purchaseOrderId)
{
var currentUser = await _userManager.GetUserAsync(User);
@@ -248,7 +248,7 @@ public class BillsController : Controller
return View("Create", dto);
}
// ── Create ───────────────────────────────────────────────────────────────
// -- Create ---------------------------------------------------------------
/// <summary>
/// Returns the blank bill creation form. When <paramref name="vendorId"/> is supplied the
@@ -257,7 +257,7 @@ public class BillsController : Controller
/// amount. The AP account is pre-filled with the first active AccountsPayable sub-type account
/// so the double-entry pair is ready without manual lookup.
/// </summary>
[Authorize(Policy = AppConstants.Policies.CanManageInventory)]
[Authorize(Policy = AppConstants.Policies.CanManageBills)]
public async Task<IActionResult> Create(int? vendorId)
{
var dto = new CreateBillDto
@@ -291,14 +291,14 @@ public class BillsController : Controller
/// review before committing to AP. Empty line items (zero account or zero price) are stripped
/// before validation to avoid spurious errors when the browser submits blank rows.
/// If <paramref name="payNow"/> is true a <see cref="BillPayment"/> record is inserted
/// immediately and the bill status is advanced to <c>Paid</c> or <c>PartiallyPaid</c> —
/// immediately and the bill status is advanced to <c>Paid</c> or <c>PartiallyPaid</c> —
/// useful for entering historical bills that were already settled. Account balance side
/// effects are deliberately deferred to <see cref="MarkOpen"/> so that Draft bills do not
/// affect the AP ledger until they are approved. If the bill was created from a PO the
/// back-reference <c>PurchaseOrder.BillId</c> is set to establish the 1:1 linkage.
/// </summary>
[HttpPost, ValidateAntiForgeryToken]
[Authorize(Policy = AppConstants.Policies.CanManageInventory)]
[Authorize(Policy = AppConstants.Policies.CanManageBills)]
public async Task<IActionResult> Create(CreateBillDto dto, IFormFile? receiptFile,
bool payNow = false,
DateTime? paymentDate = null,
@@ -321,6 +321,19 @@ public class BillsController : Controller
try
{
var currentUser = await _userManager.GetUserAsync(User);
// Period lock check — block if the bill date is in a locked period
if (currentUser != null)
{
var co = await _unitOfWork.Companies.GetByIdAsync(currentUser.CompanyId);
if (Web.Helpers.AccountingPeriodValidator.IsLocked(dto.BillDate, co?.BookLockedThrough))
{
ModelState.AddModelError("BillDate", Web.Helpers.AccountingPeriodValidator.LockedMessage(co!.BookLockedThrough));
await PopulateDropdownsAsync();
return View(dto);
}
}
Bill? bill = null;
// Bill entity, PO back-reference, and optional immediate payment all commit
@@ -386,7 +399,7 @@ public class BillsController : Controller
await _unitOfWork.CompleteAsync();
});
// Receipt upload after the transaction commits — bill.Id is set and core data
// Receipt upload after the transaction commits — bill.Id is set and core data
// is secure. A blob failure here leaves the bill intact without an attachment.
if (receiptFile != null && receiptFile.Length > 0)
{
@@ -415,7 +428,7 @@ public class BillsController : Controller
}
}
// ── Details ──────────────────────────────────────────────────────────────
// -- Details --------------------------------------------------------------
/// <summary>
/// Displays full bill detail including line items, payments, and the payment entry form.
@@ -441,7 +454,7 @@ public class BillsController : Controller
.ToList();
ViewBag.BankAccounts = bankAccounts
.Select(a => new SelectListItem($"{a.AccountNumber} {a.Name}", a.Id.ToString()))
.Select(a => new SelectListItem($"{a.AccountNumber} {a.Name}", a.Id.ToString()))
.ToList();
ViewBag.PaymentMethods = Enum.GetValues<PaymentMethod>()
@@ -451,7 +464,7 @@ public class BillsController : Controller
return View(dto);
}
// ── Edit ─────────────────────────────────────────────────────────────────
// -- Edit -----------------------------------------------------------------
/// <summary>
/// Returns the edit form for a bill. Only <c>Draft</c> bills are editable; once a bill is
@@ -459,7 +472,7 @@ public class BillsController : Controller
/// unreconciled ledger entries. Paid and Voided bills are also blocked to preserve the
/// audit trail.
/// </summary>
[Authorize(Policy = AppConstants.Policies.CanManageInventory)]
[Authorize(Policy = AppConstants.Policies.CanManageBills)]
public async Task<IActionResult> Edit(int? id)
{
if (id == null) return NotFound();
@@ -510,7 +523,7 @@ public class BillsController : Controller
/// storage; the old blob is deleted before the new one is written to avoid orphaned files.
/// </summary>
[HttpPost, ValidateAntiForgeryToken]
[Authorize(Policy = AppConstants.Policies.CanManageInventory)]
[Authorize(Policy = AppConstants.Policies.CanManageBills)]
public async Task<IActionResult> Edit(int id, EditBillDto dto, IFormFile? receiptFile)
{
if (id != dto.Id) return NotFound();
@@ -607,7 +620,7 @@ public class BillsController : Controller
}
}
// ── Mark Open (Draft Open) ─────────────────────────────────────────────
// -- Mark Open (Draft ? Open) ---------------------------------------------
/// <summary>
/// Transitions a bill from <c>Draft</c> to <c>Open</c> (the AP approval step). This is
@@ -618,7 +631,7 @@ public class BillsController : Controller
/// deferred from bill creation to give users a review window without polluting the ledger.
/// </summary>
[HttpPost, ValidateAntiForgeryToken]
[Authorize(Policy = AppConstants.Policies.CanManageInventory)]
[Authorize(Policy = AppConstants.Policies.CanManageBills)]
public async Task<IActionResult> MarkOpen(int id)
{
var bill = await _unitOfWork.Bills.GetByIdAsync(id, false, b => b.LineItems);
@@ -656,7 +669,7 @@ public class BillsController : Controller
return RedirectToAction(nameof(Details), new { id });
}
// ── Record Payment ───────────────────────────────────────────────────────
// -- Record Payment -------------------------------------------------------
/// <summary>
/// Records a full or partial payment against an open bill. Overpayment is blocked because
@@ -668,7 +681,7 @@ public class BillsController : Controller
/// any positive remainder leaves the bill in <c>PartiallyPaid</c>.
/// </summary>
[HttpPost, ValidateAntiForgeryToken]
[Authorize(Policy = AppConstants.Policies.CanManageInventory)]
[Authorize(Policy = AppConstants.Policies.CanManageBills)]
public async Task<IActionResult> RecordPayment(RecordBillPaymentDto dto)
{
if (!ModelState.IsValid)
@@ -739,7 +752,7 @@ public class BillsController : Controller
return RedirectToAction(nameof(Details), new { id = dto.BillId });
}
// ── Delete Payment ───────────────────────────────────────────────────────
// -- Delete Payment -------------------------------------------------------
/// <summary>
/// Reverses a previously recorded payment. All double-entry effects of
@@ -749,7 +762,7 @@ public class BillsController : Controller
/// <c>PartiallyPaid</c> depending on the remaining <c>AmountPaid</c> after reversal.
/// </summary>
[HttpPost, ValidateAntiForgeryToken]
[Authorize(Policy = AppConstants.Policies.CanManageInventory)]
[Authorize(Policy = AppConstants.Policies.CanManageBills)]
public async Task<IActionResult> DeletePayment(int paymentId, int billId)
{
try
@@ -796,7 +809,7 @@ public class BillsController : Controller
return RedirectToAction(nameof(Details), new { id = billId });
}
// ── Edit Payment ─────────────────────────────────────────────────────────
// -- Edit Payment ---------------------------------------------------------
/// <summary>
/// Updates non-financial attributes of a payment (date, method, check number, memo) and,
@@ -805,7 +818,7 @@ public class BillsController : Controller
/// amount on the AP side does not change so no AP balance adjustment is needed.
/// </summary>
[HttpPost, ValidateAntiForgeryToken]
[Authorize(Policy = AppConstants.Policies.CanManageInventory)]
[Authorize(Policy = AppConstants.Policies.CanManageBills)]
public async Task<IActionResult> EditPayment(EditBillPaymentDto dto)
{
if (!ModelState.IsValid)
@@ -850,11 +863,11 @@ public class BillsController : Controller
return RedirectToAction(nameof(Details), new { id = dto.BillId });
}
// ── Void ─────────────────────────────────────────────────────────────────
// -- Void -----------------------------------------------------------------
/// <summary>
/// Voids an open or partially-paid bill, removing the remaining AP liability from the ledger.
/// Only the unpaid portion (<c>BalanceDue</c>) is reversed on the AP account — any payments
/// Only the unpaid portion (<c>BalanceDue</c>) is reversed on the AP account — any payments
/// already recorded remain as historical cash transactions. The vendor balance is likewise
/// reduced only by the outstanding balance, not the total. To signal "fully settled" without
/// leaving a positive <c>BalanceDue</c>, <c>AmountPaid</c> is set equal to <c>Total</c>
@@ -909,7 +922,7 @@ public class BillsController : Controller
return RedirectToAction(nameof(Details), new { id });
}
// ── AJAX: Vendor default expense account ────────────────────────────────
// -- AJAX: Vendor default expense account --------------------------------
/// <summary>
/// AJAX endpoint that returns a vendor's default expense account and payment terms. Called by
@@ -927,7 +940,7 @@ public class BillsController : Controller
});
}
// ── Helpers ──────────────────────────────────────────────────────────────
// -- Helpers --------------------------------------------------------------
/// <summary>
/// Loads all dropdown lists needed by the Create and Edit views into <c>ViewBag</c>: vendors,
@@ -966,7 +979,7 @@ public class BillsController : Controller
/// <summary>
/// Generates a sequential payment reference number in the format <c>BPMT-YYMM-####</c>.
/// Same monotonic sequence logic as <see cref="GenerateBillNumberAsync"/> — soft-deleted
/// Same monotonic sequence logic as <see cref="GenerateBillNumberAsync"/> — soft-deleted
/// records are included in the scan so payment numbers are never reused.
/// </summary>
private async Task<string> GeneratePaymentNumberAsync()
@@ -981,7 +994,7 @@ public class BillsController : Controller
return $"{prefix}{next:D4}";
}
// ── Receipt File: Download / Remove ─────────────────────────────────────
// -- Receipt File: Download / Remove -------------------------------------
/// <summary>
/// Downloads the receipt attachment for a bill as a file-download response. Unlike expense
@@ -1009,7 +1022,7 @@ public class BillsController : Controller
/// window where the UI shows a broken attachment link.
/// </summary>
[HttpPost, ValidateAntiForgeryToken]
[Authorize(Policy = AppConstants.Policies.CanManageInventory)]
[Authorize(Policy = AppConstants.Policies.CanManageBills)]
public async Task<IActionResult> RemoveReceipt(int id)
{
var bill = await _unitOfWork.Bills.GetByIdAsync(id);
@@ -1026,7 +1039,7 @@ public class BillsController : Controller
return RedirectToAction(nameof(Details), new { id });
}
// ── AI: Receipt Scanning ─────────────────────────────────────────────────
// -- AI: Receipt Scanning -------------------------------------------------
/// <summary>
/// AI-powered receipt scanning endpoint. Accepts an image or PDF of a vendor receipt, passes
@@ -1038,7 +1051,7 @@ public class BillsController : Controller
/// model can match categories to the company's specific chart of accounts.
/// </summary>
[HttpPost]
[Authorize(Policy = AppConstants.Policies.CanManageInventory)]
[Authorize(Policy = AppConstants.Policies.CanManageBills)]
[EnableRateLimiting(AppConstants.RateLimitPolicies.Ai)]
public async Task<IActionResult> ScanReceipt(IFormFile? receiptImage)
{
@@ -1079,7 +1092,7 @@ public class BillsController : Controller
return Json(result);
}
// ── AI: Account Suggestion ────────────────────────────────────────────────
// -- AI: Account Suggestion ------------------------------------------------
/// <summary>
/// AI-powered account categorisation for a single bill line item. When the caller does not
@@ -1090,7 +1103,7 @@ public class BillsController : Controller
/// full account list in the DOM. Rate-limited to the <c>Ai</c> policy.
/// </summary>
[HttpPost]
[Authorize(Policy = AppConstants.Policies.CanManageInventory)]
[Authorize(Policy = AppConstants.Policies.CanManageBills)]
[EnableRateLimiting(AppConstants.RateLimitPolicies.Ai)]
public async Task<IActionResult> SuggestAccount([FromBody] AccountSuggestionRequest request)
{
@@ -1123,7 +1136,69 @@ public class BillsController : Controller
return Json(result);
}
// ── Receipt File Helpers ──────────────────────────────────────────────────
// -- AI: Recurring Bill Detection ------------------------------------------
/// <summary>
/// GET page — displays the recurring bill detection tool. No data is pre-fetched here;
/// the user triggers the scan by clicking a button which calls <see cref="RunRecurringDetection"/>.
/// </summary>
public IActionResult RecurringDetection() => View();
/// <summary>
/// AJAX POST — loads up to 12 months of bill history for the company and passes it to
/// Claude for recurring pattern analysis. Only posted bills (Draft/Open/Partial/Paid) are
/// included; Voided bills are excluded so cancelled payments do not distort the pattern.
/// Results are returned as JSON for client-side rendering in the view.
/// </summary>
[HttpPost]
[EnableRateLimiting(AppConstants.RateLimitPolicies.Ai)]
[ValidateAntiForgeryToken]
public async Task<IActionResult> RunRecurringDetection()
{
try
{
var companyId = int.TryParse(User.FindFirst("CompanyId")?.Value, out var cid) ? cid : 0;
var cutoff = DateTime.Today.AddMonths(-12);
var bills = (await _unitOfWork.Bills.GetAllAsync(false, b => b.Vendor))
.Where(b => b.Status != BillStatus.Voided && b.BillDate >= cutoff)
.ToList();
if (!bills.Any())
return Json(new RecurringBillDetectionResult
{
Success = true,
Insights = new List<string> { "No bill history found in the last 12 months." }
});
var companyName = (await _unitOfWork.Companies.GetByIdAsync(companyId))?.CompanyName ?? "Your Company";
var request = new RecurringBillDetectionRequest
{
CompanyName = companyName,
Bills = bills.Select(b => new RecurringBillHistoryItem
{
VendorName = b.Vendor?.CompanyName ?? $"Vendor #{b.VendorId}",
BillNumber = b.BillNumber,
Amount = b.Total,
DateIso = b.BillDate.ToString("yyyy-MM-dd"),
Memo = b.Memo
}).ToList()
};
var result = await _accountingAi.DetectRecurringBillsAsync(request);
var userId = User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value ?? "";
await _usageLogger.LogAsync(companyId, userId, AppConstants.AiFeatures.RecurringBillDetection, result.Success);
return Json(result);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error running recurring bill detection");
return Json(new RecurringBillDetectionResult { Success = false, ErrorMessage = "An error occurred while analyzing bill patterns." });
}
}
// -- Receipt File Helpers --------------------------------------------------
/// <summary>
/// Uploads a receipt file to Azure Blob Storage under the path
@@ -0,0 +1,302 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Rendering;
using PowderCoating.Core.Entities;
using PowderCoating.Core.Enums;
using PowderCoating.Core.Interfaces;
using PowderCoating.Shared.Constants;
namespace PowderCoating.Web.Controllers;
/// <summary>
/// Manages annual budgets. Each budget has one BudgetLine per active GL account with
/// monthly amounts (JanDec). The Budget vs. Actual report compares these to real activity.
/// Only one budget per year is marked IsDefault — that one feeds the variance report automatically.
/// </summary>
[Authorize(Policy = AppConstants.Policies.CompanyAdminOnly)]
public class BudgetsController : Controller
{
private readonly IUnitOfWork _unitOfWork;
private readonly ITenantContext _tenantContext;
public BudgetsController(IUnitOfWork unitOfWork, ITenantContext tenantContext)
{
_unitOfWork = unitOfWork;
_tenantContext = tenantContext;
}
// ── Index ─────────────────────────────────────────────────────────────────
/// <summary>Lists all budgets for the current company ordered by fiscal year descending.</summary>
public async Task<IActionResult> Index()
{
var budgets = (await _unitOfWork.Budgets.FindAsync(b => true, false, b => b.Lines))
.OrderByDescending(b => b.FiscalYear)
.ThenBy(b => b.Name)
.ToList();
return View(budgets);
}
// ── Create ────────────────────────────────────────────────────────────────
[HttpGet]
public async Task<IActionResult> Create()
{
var accounts = await GetBudgetableAccountsAsync();
return View(new BudgetCreateVm
{
FiscalYear = DateTime.Now.Year,
Lines = accounts.Select(a => new BudgetLineVm { AccountId = a.Id, AccountNumber = a.AccountNumber, AccountName = a.Name, AccountType = a.AccountType }).ToList()
});
}
[HttpPost, ValidateAntiForgeryToken]
public async Task<IActionResult> Create(BudgetCreateVm vm)
{
if (!ModelState.IsValid) return View(vm);
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
// If this is marked default, clear the flag on other budgets for the same year
if (vm.IsDefault)
await ClearDefaultFlagAsync(companyId, vm.FiscalYear, excludeId: null);
var budget = new Budget
{
Name = vm.Name,
FiscalYear = vm.FiscalYear,
Notes = vm.Notes,
IsDefault = vm.IsDefault,
CompanyId = companyId,
CreatedAt = DateTime.UtcNow,
Lines = vm.Lines
.Where(l => l.HasAnyAmount)
.Select(l => new BudgetLine
{
AccountId = l.AccountId,
Jan = l.Jan, Feb = l.Feb, Mar = l.Mar, Apr = l.Apr,
May = l.May, Jun = l.Jun, Jul = l.Jul, Aug = l.Aug,
Sep = l.Sep, Oct = l.Oct, Nov = l.Nov, Dec = l.Dec,
CompanyId = companyId, CreatedAt = DateTime.UtcNow
}).ToList()
};
await _unitOfWork.Budgets.AddAsync(budget);
await _unitOfWork.CompleteAsync();
TempData["Success"] = $"Budget \"{budget.Name}\" created for {budget.FiscalYear}.";
return RedirectToAction(nameof(Edit), new { id = budget.Id });
}
// ── Edit ──────────────────────────────────────────────────────────────────
[HttpGet]
public async Task<IActionResult> Edit(int id)
{
var budget = await _unitOfWork.Budgets.GetByIdAsync(id, false, b => b.Lines);
if (budget == null) return NotFound();
var accounts = await GetBudgetableAccountsAsync();
var lineMap = budget.Lines.ToDictionary(l => l.AccountId);
var vm = new BudgetCreateVm
{
Id = budget.Id,
Name = budget.Name,
FiscalYear = budget.FiscalYear,
Notes = budget.Notes,
IsDefault = budget.IsDefault,
Lines = accounts.Select(a =>
{
lineMap.TryGetValue(a.Id, out var existing);
return new BudgetLineVm
{
AccountId = a.Id,
AccountNumber = a.AccountNumber,
AccountName = a.Name,
AccountType = a.AccountType,
Jan = existing?.Jan ?? 0, Feb = existing?.Feb ?? 0, Mar = existing?.Mar ?? 0,
Apr = existing?.Apr ?? 0, May = existing?.May ?? 0, Jun = existing?.Jun ?? 0,
Jul = existing?.Jul ?? 0, Aug = existing?.Aug ?? 0, Sep = existing?.Sep ?? 0,
Oct = existing?.Oct ?? 0, Nov = existing?.Nov ?? 0, Dec = existing?.Dec ?? 0
};
}).ToList()
};
return View(vm);
}
[HttpPost, ValidateAntiForgeryToken]
public async Task<IActionResult> Edit(int id, BudgetCreateVm vm)
{
if (id != vm.Id) return BadRequest();
if (!ModelState.IsValid) return View(vm);
var budget = await _unitOfWork.Budgets.GetByIdAsync(id, false, b => b.Lines);
if (budget == null) return NotFound();
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
if (vm.IsDefault && !budget.IsDefault)
await ClearDefaultFlagAsync(companyId, vm.FiscalYear, excludeId: id);
budget.Name = vm.Name;
budget.Notes = vm.Notes;
budget.IsDefault = vm.IsDefault;
budget.UpdatedAt = DateTime.UtcNow;
// Delete old lines and replace with new set (simpler than merge)
foreach (var line in budget.Lines.ToList())
await _unitOfWork.BudgetLines.SoftDeleteAsync(line.Id);
budget.Lines = vm.Lines
.Where(l => l.HasAnyAmount)
.Select(l => new BudgetLine
{
AccountId = l.AccountId,
Jan = l.Jan, Feb = l.Feb, Mar = l.Mar, Apr = l.Apr,
May = l.May, Jun = l.Jun, Jul = l.Jul, Aug = l.Aug,
Sep = l.Sep, Oct = l.Oct, Nov = l.Nov, Dec = l.Dec,
CompanyId = companyId, CreatedAt = DateTime.UtcNow
}).ToList();
await _unitOfWork.CompleteAsync();
TempData["Success"] = $"Budget \"{budget.Name}\" saved.";
return RedirectToAction(nameof(Edit), new { id });
}
// ── Copy ─────────────────────────────────────────────────────────────────
/// <summary>
/// Creates a copy of an existing budget for a new fiscal year — common workflow for
/// rolling forward last year's budget as a starting point.
/// </summary>
[HttpPost, ValidateAntiForgeryToken]
public async Task<IActionResult> Copy(int id, int newYear)
{
var source = await _unitOfWork.Budgets.GetByIdAsync(id, false, b => b.Lines);
if (source == null) return NotFound();
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var copy = new Budget
{
Name = $"{source.Name} ({newYear})",
FiscalYear = newYear,
Notes = source.Notes,
IsDefault = false,
CompanyId = companyId,
CreatedAt = DateTime.UtcNow,
Lines = source.Lines.Select(l => new BudgetLine
{
AccountId = l.AccountId,
Jan = l.Jan, Feb = l.Feb, Mar = l.Mar, Apr = l.Apr,
May = l.May, Jun = l.Jun, Jul = l.Jul, Aug = l.Aug,
Sep = l.Sep, Oct = l.Oct, Nov = l.Nov, Dec = l.Dec,
CompanyId = companyId, CreatedAt = DateTime.UtcNow
}).ToList()
};
await _unitOfWork.Budgets.AddAsync(copy);
await _unitOfWork.CompleteAsync();
TempData["Success"] = $"Budget copied to {newYear}.";
return RedirectToAction(nameof(Edit), new { id = copy.Id });
}
// ── SetDefault ────────────────────────────────────────────────────────────
[HttpPost, ValidateAntiForgeryToken]
public async Task<IActionResult> SetDefault(int id)
{
var budget = await _unitOfWork.Budgets.GetByIdAsync(id);
if (budget == null) return NotFound();
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
await ClearDefaultFlagAsync(companyId, budget.FiscalYear, excludeId: null);
budget.IsDefault = true;
budget.UpdatedAt = DateTime.UtcNow;
await _unitOfWork.CompleteAsync();
TempData["Success"] = $"\"{budget.Name}\" is now the default budget for {budget.FiscalYear}.";
return RedirectToAction(nameof(Index));
}
// ── Delete ────────────────────────────────────────────────────────────────
[HttpPost, ValidateAntiForgeryToken]
public async Task<IActionResult> Delete(int id)
{
var budget = await _unitOfWork.Budgets.GetByIdAsync(id, false, b => b.Lines);
if (budget == null) return NotFound();
foreach (var line in budget.Lines.ToList())
await _unitOfWork.BudgetLines.SoftDeleteAsync(line.Id);
await _unitOfWork.Budgets.SoftDeleteAsync(id);
await _unitOfWork.CompleteAsync();
TempData["Success"] = $"Budget \"{budget.Name}\" deleted.";
return RedirectToAction(nameof(Index));
}
// ── Helpers ───────────────────────────────────────────────────────────────
private async Task<List<Account>> GetBudgetableAccountsAsync()
{
var accounts = await _unitOfWork.Accounts.FindAsync(
a => a.IsActive && (a.AccountType == AccountType.Revenue || a.AccountType == AccountType.Expense));
return accounts.OrderBy(a => a.AccountNumber).ToList();
}
private async Task ClearDefaultFlagAsync(int companyId, int fiscalYear, int? excludeId)
{
var others = await _unitOfWork.Budgets.FindAsync(
b => b.IsDefault && b.FiscalYear == fiscalYear && b.Id != (excludeId ?? 0));
foreach (var b in others)
{
b.IsDefault = false;
b.UpdatedAt = DateTime.UtcNow;
}
if (others.Any())
await _unitOfWork.CompleteAsync();
}
}
// ── View Models ───────────────────────────────────────────────────────────────
public class BudgetCreateVm
{
public int Id { get; set; }
public string Name { get; set; } = string.Empty;
public int FiscalYear { get; set; } = DateTime.Now.Year;
public string? Notes { get; set; }
public bool IsDefault { get; set; } = true;
public List<BudgetLineVm> Lines { get; set; } = new();
}
public class BudgetLineVm
{
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 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 bool HasAnyAmount => Annual != 0;
}
@@ -66,15 +66,16 @@ public class CompaniesController : Controller
string sortColumn = "CompanyName",
string sortDirection = "asc",
int pageNumber = 1,
int pageSize = 25)
int pageSize = 25,
bool showChurned = false)
{
try
{
pageNumber = Math.Max(1, pageNumber);
pageSize = pageSize is 10 or 25 or 50 or 100 ? pageSize : 25;
var (companies, totalCount) = await _companyList.GetPagedAsync(
searchTerm, sortColumn, sortDirection, pageNumber, pageSize);
var (companies, totalCount, churnedCount) = await _companyList.GetPagedAsync(
searchTerm, sortColumn, sortDirection, pageNumber, pageSize, hideChurned: !showChurned);
var companyDtos = _mapper.Map<List<CompanyListDto>>(companies);
@@ -82,6 +83,8 @@ public class CompaniesController : Controller
{
var ids = companyDtos.Select(c => c.Id).ToList();
var summary = await _companyList.GetCountSummaryAsync(ids);
var companyById = companies.ToDictionary(c => c.Id);
var now = DateTime.UtcNow;
foreach (var dto in companyDtos)
{
@@ -95,6 +98,23 @@ public class CompaniesController : Controller
dto.WizardCompletedAt = w.CompletedAt;
dto.WizardCompletedByName = w.CompletedByName;
}
// Health badge
var lastLogin = summary.LastLoginDates.TryGetValue(dto.Id, out var ll) ? ll : null;
var daysSince = lastLogin.HasValue ? (int)(now - lastLogin.Value).TotalDays : -1;
var j30 = summary.Jobs30Counts.GetValueOrDefault(dto.Id, 0);
var j90 = summary.Jobs90Counts.GetValueOrDefault(dto.Id, 0);
if (companyById.TryGetValue(dto.Id, out var co))
{
var (score, _) = CompanyHealthHelper.ComputeHealth(co, daysSince, j30, j90, dto.JobCount, now);
var neverActivated = dto.JobCount == 0 && dto.CustomerCount == 0 && dto.QuoteCount == 0
&& dto.CreatedAt < now.AddDays(-7);
dto.HealthScore = score;
dto.HealthRisk = CompanyHealthHelper.ToRiskLevel(score, neverActivated).ToString();
}
dto.LastLoginDate = lastLogin;
}
}
@@ -109,6 +129,8 @@ public class CompaniesController : Controller
ViewBag.PageSize = pageSize;
ViewBag.TotalPages = (int)Math.Ceiling(totalCount / (double)pageSize);
ViewBag.ImpersonatingCompanyId = HttpContext.Session.GetInt32("ImpersonatingCompanyId");
ViewBag.ShowChurned = showChurned;
ViewBag.ChurnedCount = churnedCount;
return View(companyDtos);
}
@@ -183,7 +205,8 @@ public class CompaniesController : Controller
.GetByIdAsync(id, ignoreQueryFilters: true,
c => c.Users,
c => c.Customers,
c => c.Jobs);
c => c.Jobs,
c => c.Preferences!);
if (company == null)
{
@@ -196,6 +219,51 @@ public class CompaniesController : Controller
ViewBag.PlanConfigs = (await _unitOfWork.SubscriptionPlanConfigs.FindAsync(
c => c.IsActive, ignoreQueryFilters: true)).OrderBy(c => c.SortOrder).ToList();
// Health data
var summary = await _companyList.GetCountSummaryAsync(new[] { id });
var now = DateTime.UtcNow;
var lastLogin = summary.LastLoginDates.TryGetValue(id, out var ll) ? ll : null;
var daysSince = lastLogin.HasValue ? (int)(now - lastLogin.Value).TotalDays : -1;
var j30 = summary.Jobs30Counts.GetValueOrDefault(id, 0);
var j90 = summary.Jobs90Counts.GetValueOrDefault(id, 0);
var totalJobs = companyDto.JobCount;
var totalCust = companyDto.CustomerCount;
var totalQuotes = summary.QuoteCounts.GetValueOrDefault(id, 0);
var (healthScore, healthSignals) = CompanyHealthHelper.ComputeHealth(company, daysSince, j30, j90, totalJobs, now);
var neverActivated = totalJobs == 0 && totalCust == 0 && totalQuotes == 0
&& company.CreatedAt < now.AddDays(-7);
var riskLevel = CompanyHealthHelper.ToRiskLevel(healthScore, neverActivated);
ViewBag.HealthScore = healthScore;
ViewBag.HealthRisk = riskLevel.ToString();
ViewBag.HealthSignals = healthSignals;
ViewBag.Jobs30 = j30;
ViewBag.Jobs90 = j90;
ViewBag.LastLoginDate = lastLogin;
// Onboarding data (from Preferences)
var prefs = company.Preferences;
int steps = 0;
if (prefs?.FirstJobCreatedAt.HasValue == true || prefs?.FirstQuoteCreatedAt.HasValue == true) steps++;
if (prefs?.FirstInvoiceCreatedAt.HasValue == true) steps++;
if (prefs?.FirstWorkflowCompletedAt.HasValue == true) steps++;
ViewBag.Onboarding = new PowderCoating.Web.ViewModels.Platform.OnboardingProgressRowViewModel
{
CompanyId = company.Id,
CompanyName = company.CompanyName ?? "",
WizardCompleted = prefs?.SetupWizardCompleted ?? false,
OnboardingPath = prefs?.OnboardingPath,
StepsCompleted = steps,
TotalSteps = 3,
FirstJobCreatedAt = prefs?.FirstJobCreatedAt,
FirstQuoteCreatedAt = prefs?.FirstQuoteCreatedAt,
FirstInvoiceCreatedAt = prefs?.FirstInvoiceCreatedAt,
FirstWorkflowCompletedAt = prefs?.FirstWorkflowCompletedAt,
GuidedActivationDismissedAt = prefs?.GuidedActivationDismissedAt,
};
return View(companyDto);
}
catch (Exception ex)
@@ -45,18 +45,30 @@ public class CompanyHealthController : Controller
/// user's risk/search filters, so the KPI cards always show platform-wide totals.
/// </para>
/// </summary>
public async Task<IActionResult> Index(string? risk, string? search, bool configIssuesOnly = false)
public async Task<IActionResult> Index(string? risk, string? search, bool configIssuesOnly = false, bool showChurned = false)
{
var now = DateTime.UtcNow;
var d30 = now.AddDays(-30);
var d90 = now.AddDays(-90);
var churnedCutoff = now.AddDays(-14);
// One query per signal — all keyed by CompanyId
var companies = await _db.Companies
var allCompanies = await _db.Companies
.AsNoTracking().IgnoreQueryFilters()
.Where(c => !c.IsDeleted)
.ToListAsync();
var churnedCount = allCompanies.Count(c =>
(c.SubscriptionStatus == SubscriptionStatus.Expired || c.SubscriptionStatus == SubscriptionStatus.Canceled)
&& c.SubscriptionEndDate.HasValue && c.SubscriptionEndDate.Value < churnedCutoff);
var companies = showChurned
? allCompanies
: allCompanies.Where(c =>
!((c.SubscriptionStatus == SubscriptionStatus.Expired || c.SubscriptionStatus == SubscriptionStatus.Canceled)
&& c.SubscriptionEndDate.HasValue && c.SubscriptionEndDate.Value < churnedCutoff))
.ToList();
var lastLogins = await _db.Users
.AsNoTracking().IgnoreQueryFilters()
.Where(u => u.LastLoginDate != null)
@@ -118,15 +130,12 @@ public class CompanyHealthController : Controller
var tquotes = totalQuotes.TryGetValue(c.Id, out var tq) ? tq : 0;
var planName = planNames.TryGetValue(c.SubscriptionPlan, out var pn) ? pn : c.SubscriptionPlan.ToString();
var (score, signals) = ComputeHealth(c, daysSince, j30v, j90v, tjobs, now);
var (score, signals) = CompanyHealthHelper.ComputeHealth(c, daysSince, j30v, j90v, tjobs, now);
var neverActivated = tjobs == 0 && tcust == 0 && tquotes == 0
&& c.CreatedAt < now.AddDays(-7);
var riskLevel = neverActivated ? ChurnRisk.NeverActivated
: score >= 75 ? ChurnRisk.Healthy
: score >= 45 ? ChurnRisk.AtRisk
: ChurnRisk.Critical;
var riskLevel = CompanyHealthHelper.ToRiskLevel(score, neverActivated);
var configHealth = configHealthMap.TryGetValue(c.Id, out var ch)
? ch : new CompanyConfigHealth { CompanyId = c.Id };
@@ -166,6 +175,8 @@ public class CompanyHealthController : Controller
ViewBag.Risk = risk;
ViewBag.Search = search;
ViewBag.ConfigIssuesOnly = configIssuesOnly;
ViewBag.ShowChurned = showChurned;
ViewBag.ChurnedCount = churnedCount;
if (!string.IsNullOrWhiteSpace(search))
all = all.Where(h =>
@@ -187,112 +198,10 @@ public class CompanyHealthController : Controller
return View(all);
}
// ── Health score algorithm ──────────────────────────────────────────────────
/// <summary>
/// Computes a 0100 health score and a list of human-readable risk signals for a
/// single company based on its subscription status, login recency, and job activity.
/// <para>
/// Scoring rules (penalties are cumulative, floor is 0):
/// <list type="bullet">
/// <item>Disabled account: score immediately set to 0, no further evaluation.</item>
/// <item>Subscription expired past the grace period: 50 pts.</item>
/// <item>Subscription within grace period: 30 pts.</item>
/// <item>Subscription expiring within 7 days: 20 pts; within 14 days: 10 pts.</item>
/// <item>Comped companies skip subscription checks entirely.</item>
/// <item>Never logged in: 30 pts; no login in 90+ days: 30; 60+d: 20; 30+d: 10.</item>
/// <item>No jobs ever: 20 pts; no jobs in last 90 days: 10; no jobs in 30d: 5.</item>
/// </list>
/// A <c>daysSinceLogin</c> value of 1 means "never logged in" and is distinct
/// from "logged in exactly 0 days ago" (i.e. today).
/// </para>
/// </summary>
private static (int score, List<string> signals) ComputeHealth(
PowderCoating.Core.Entities.Company c, int daysSinceLogin,
int j30, int j90, int totalJobs, DateTime now)
{
var score = 100;
var signals = new List<string>();
if (!c.IsActive)
{
signals.Add("Account disabled");
return (0, signals);
}
// Subscription health (skip for comped)
if (!c.IsComped && c.SubscriptionEndDate.HasValue)
{
var daysUntil = (int)(c.SubscriptionEndDate.Value.Date - now.Date).TotalDays;
if (daysUntil < -AppConstants.SubscriptionConstants.GracePeriodDays)
{
score -= 50;
signals.Add("Subscription expired");
}
else if (daysUntil < 0)
{
score -= 30;
signals.Add("In grace period");
}
else if (daysUntil <= 7)
{
score -= 20;
signals.Add($"Expires in {daysUntil}d");
}
else if (daysUntil <= 14)
{
score -= 10;
signals.Add($"Expires in {daysUntil}d");
}
}
// Login activity
if (daysSinceLogin == -1)
{
score -= 30;
signals.Add("Never logged in");
}
else if (daysSinceLogin >= 90)
{
score -= 30;
signals.Add($"No login {daysSinceLogin}d");
}
else if (daysSinceLogin >= 60)
{
score -= 20;
signals.Add($"No login {daysSinceLogin}d");
}
else if (daysSinceLogin >= 30)
{
score -= 10;
signals.Add($"No login {daysSinceLogin}d");
}
// Job activity
if (totalJobs == 0)
{
score -= 20;
signals.Add("No jobs ever");
}
else if (j90 == 0)
{
score -= 10;
signals.Add("No jobs in 90d");
}
else if (j30 == 0)
{
score -= 5;
signals.Add("No jobs in 30d");
}
return (Math.Max(0, score), signals);
}
}
// ── View models ────────────────────────────────────────────────────────────────
public enum ChurnRisk { Healthy, AtRisk, Critical, NeverActivated }
public class CompanyHealthDto
{
public int Id { get; set; }
@@ -0,0 +1,105 @@
using PowderCoating.Core.Entities;
using PowderCoating.Shared.Constants;
namespace PowderCoating.Web.Controllers;
/// <summary>Risk bucket for a tenant company, derived from its health score.</summary>
public enum ChurnRisk { Healthy, AtRisk, Critical, NeverActivated }
/// <summary>
/// Shared health-score logic used by both <see cref="CompanyHealthController"/> (dashboard)
/// and <see cref="CompaniesController"/> (list + detail badges).
/// </summary>
public static class CompanyHealthHelper
{
/// <summary>
/// Computes a 0100 health score and a list of human-readable risk signals for a single
/// company based on its subscription status, login recency, and job activity.
/// See <see cref="CompanyHealthController"/> XML doc for scoring rules.
/// </summary>
public static (int Score, List<string> Signals) ComputeHealth(
Company c, int daysSinceLogin, int j30, int j90, int totalJobs, DateTime now)
{
var score = 100;
var signals = new List<string>();
if (!c.IsActive)
{
signals.Add("Account disabled");
return (0, signals);
}
if (!c.IsComped && c.SubscriptionEndDate.HasValue)
{
var daysUntil = (int)(c.SubscriptionEndDate.Value.Date - now.Date).TotalDays;
if (daysUntil < -AppConstants.SubscriptionConstants.GracePeriodDays)
{
score -= 50;
signals.Add("Subscription expired");
}
else if (daysUntil < 0)
{
score -= 30;
signals.Add("In grace period");
}
else if (daysUntil <= 7)
{
score -= 20;
signals.Add($"Expires in {daysUntil}d");
}
else if (daysUntil <= 14)
{
score -= 10;
signals.Add($"Expires in {daysUntil}d");
}
}
if (daysSinceLogin == -1)
{
score -= 30;
signals.Add("Never logged in");
}
else if (daysSinceLogin >= 90)
{
score -= 30;
signals.Add($"No login {daysSinceLogin}d");
}
else if (daysSinceLogin >= 60)
{
score -= 20;
signals.Add($"No login {daysSinceLogin}d");
}
else if (daysSinceLogin >= 30)
{
score -= 10;
signals.Add($"No login {daysSinceLogin}d");
}
if (totalJobs == 0)
{
score -= 20;
signals.Add("No jobs ever");
}
else if (j90 == 0)
{
score -= 10;
signals.Add("No jobs in 90d");
}
else if (j30 == 0)
{
score -= 5;
signals.Add("No jobs in 30d");
}
return (Math.Max(0, score), signals);
}
/// <summary>
/// Derives a <see cref="ChurnRisk"/> bucket from a pre-computed score and activity flags.
/// </summary>
public static ChurnRisk ToRiskLevel(int score, bool neverActivated) =>
neverActivated ? ChurnRisk.NeverActivated
: score >= 75 ? ChurnRisk.Healthy
: score >= 45 ? ChurnRisk.AtRisk
: ChurnRisk.Critical;
}
@@ -160,6 +160,10 @@ public class CompanySettingsController : Controller
UpdatedAt = t.UpdatedAt
}).ToList();
ViewBag.BookLockedThrough = company.BookLockedThrough.HasValue
? (DateTime?)company.BookLockedThrough.Value.ToLocalTime()
: null;
return View(dto);
}
catch (FormatException fex)
@@ -227,6 +231,34 @@ public class CompanySettingsController : Controller
}
}
/// <summary>
/// Locks the books through the given date, preventing new or edited accounting entries
/// (JEs, bills, expenses) from being dated on or before this date. Null clears the lock.
/// </summary>
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> SetPeriodLock(DateTime? lockThrough)
{
var companyId = _tenantContext.GetCurrentCompanyId();
if (companyId == null) return BadRequest();
var company = await _unitOfWork.Companies.GetByIdAsync(companyId.Value);
if (company == null) return NotFound();
company.BookLockedThrough = lockThrough.HasValue
? DateTime.SpecifyKind(lockThrough.Value.Date, DateTimeKind.Utc)
: null;
company.UpdatedAt = DateTime.UtcNow;
await _unitOfWork.Companies.UpdateAsync(company);
await _unitOfWork.CompleteAsync();
TempData["Success"] = lockThrough.HasValue
? $"Books locked through {lockThrough.Value:MMMM d, yyyy}."
: "Period lock cleared — all periods are now open.";
return RedirectToAction(nameof(Index), null, "company-info");
}
/// <summary>
/// Serves the current company's logo as a binary file response. Logos are stored on the filesystem
/// via <see cref="ICompanyLogoService"/> (primary) or as raw bytes in <c>Company.LogoData</c>
@@ -511,6 +543,15 @@ public class CompanySettingsController : Controller
public Task<IActionResult> UpdateWorkOrderTemplate([FromBody] UpdateWorkOrderTemplateDto dto) =>
UpdatePreferences(dto, "Work order settings saved successfully.");
/// <summary>
/// Saves kiosk intake output preference ("Quote" or "Job") to <see cref="CompanyPreferences"/>.
/// Delegates to <see cref="UpdatePreferences{TDto}"/>.
/// </summary>
// POST: CompanySettings/UpdateKioskSettings
[HttpPost]
public Task<IActionResult> UpdateKioskSettings([FromBody] UpdateKioskSettingsDto dto) =>
UpdatePreferences(dto, "Kiosk settings saved successfully.");
/// <summary>
/// Persists the company's pricing model parameters — labor rates, sandblasting/masking multipliers,
/// oven cost per hour, overhead admin/facility percentages, profit margin, and default tax rate —
@@ -2653,6 +2694,7 @@ public class CompanySettingsController : Controller
{
list.Add(("{{invoiceTotal}}", "Invoice total amount (formatted as currency)"));
list.Add(("{{invoiceDueDate}}", "Due date phrase, e.g. \" Due by January 1, 2026.\" — blank if no due date is set"));
list.Add(("{{viewUrl}}", "Permanent link for the customer to view the invoice online (used in SMS)"));
}
if (type == NotificationType.PaymentReceived)
@@ -277,6 +277,7 @@ public class CompanyUsersController : Controller
{
AppConstants.CompanyRoles.CompanyAdmin,
AppConstants.CompanyRoles.Manager,
AppConstants.CompanyRoles.Accountant,
AppConstants.CompanyRoles.Worker,
AppConstants.CompanyRoles.Viewer
};
@@ -329,7 +330,9 @@ public class CompanyUsersController : Controller
CanManageVendors = forceAllPermissions || model.CanManageVendors,
CanManageMaintenance = forceAllPermissions || model.CanManageMaintenance,
CanManageInvoices = forceAllPermissions || model.CanManageInvoices,
CanViewReports = forceAllPermissions || model.CanViewReports
CanViewReports = forceAllPermissions || model.CanViewReports,
CanManageBills = forceAllPermissions || model.CanManageBills,
CanManageAccounting = forceAllPermissions || model.CanManageAccounting
};
var result = await _userManager.CreateAsync(user, model.Password);
@@ -341,6 +344,7 @@ public class CompanyUsersController : Controller
{
AppConstants.CompanyRoles.CompanyAdmin => AppConstants.Roles.Administrator,
AppConstants.CompanyRoles.Manager => AppConstants.Roles.Manager,
AppConstants.CompanyRoles.Accountant => AppConstants.Roles.Employee,
AppConstants.CompanyRoles.Worker => AppConstants.Roles.Employee,
_ => AppConstants.Roles.ReadOnly
};
@@ -454,7 +458,9 @@ public class CompanyUsersController : Controller
CanManageVendors = user.CanManageVendors,
CanManageMaintenance = user.CanManageMaintenance,
CanManageInvoices = user.CanManageInvoices,
CanViewReports = user.CanViewReports
CanViewReports = user.CanViewReports,
CanManageBills = user.CanManageBills,
CanManageAccounting = user.CanManageAccounting
};
ViewBag.ReturnUrl = returnUrl;
@@ -538,6 +544,7 @@ public class CompanyUsersController : Controller
{
AppConstants.CompanyRoles.CompanyAdmin,
AppConstants.CompanyRoles.Manager,
AppConstants.CompanyRoles.Accountant,
AppConstants.CompanyRoles.Worker,
AppConstants.CompanyRoles.Viewer
};
@@ -608,6 +615,8 @@ public class CompanyUsersController : Controller
user.CanManageMaintenance = forceAllPermissions || model.CanManageMaintenance;
user.CanManageInvoices = forceAllPermissions || model.CanManageInvoices;
user.CanViewReports = forceAllPermissions || model.CanViewReports;
user.CanManageBills = forceAllPermissions || model.CanManageBills;
user.CanManageAccounting = forceAllPermissions || model.CanManageAccounting;
user.UpdatedAt = DateTime.UtcNow;
var result = await _userManager.UpdateAsync(user);
@@ -0,0 +1,376 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Rendering;
using PowderCoating.Application.Interfaces;
using PowderCoating.Core.Entities;
using PowderCoating.Core.Enums;
using PowderCoating.Core.Interfaces;
using PowderCoating.Shared.Constants;
using System.ComponentModel.DataAnnotations;
namespace PowderCoating.Web.Controllers;
/// <summary>
/// Manages the company-wide credit memo register. Credit memos reduce a customer's outstanding
/// balance and can be issued standalone (goodwill, billing correction) or linked to an original
/// invoice (price dispute, rework resolution). Applied portions reduce invoice BalanceDue and
/// customer.CreditBalance atomically inside a transaction.
/// GL entries on Apply: DR 4950 Sales Discounts (contra-revenue) / CR AR — mirrors the treatment
/// of invoice discounts so the Trial Balance and Balance Sheet reflect the applied credit as both
/// a revenue deduction and an AR reduction.
/// </summary>
[Authorize(Policy = AppConstants.Policies.CompanyAdminOnly)]
public class CreditMemosController : Controller
{
private readonly IUnitOfWork _unitOfWork;
private readonly ITenantContext _tenantContext;
private readonly UserManager<ApplicationUser> _userManager;
private readonly ILogger<CreditMemosController> _logger;
private readonly IAccountBalanceService _accountBalanceService;
public CreditMemosController(
IUnitOfWork unitOfWork,
ITenantContext tenantContext,
UserManager<ApplicationUser> userManager,
ILogger<CreditMemosController> logger,
IAccountBalanceService accountBalanceService)
{
_unitOfWork = unitOfWork;
_tenantContext = tenantContext;
_userManager = userManager;
_logger = logger;
_accountBalanceService = accountBalanceService;
}
/// <summary>Lists all credit memos for the current company with optional status and text filters.</summary>
[HttpGet]
public async Task<IActionResult> Index(string? status, string? search)
{
var memos = await _unitOfWork.CreditMemos.FindAsync(
m => true, false,
m => m.Customer);
if (!string.IsNullOrWhiteSpace(search))
memos = memos.Where(m =>
DisplayName(m.Customer).Contains(search, StringComparison.OrdinalIgnoreCase) ||
m.MemoNumber.Contains(search, StringComparison.OrdinalIgnoreCase) ||
m.Reason.Contains(search, StringComparison.OrdinalIgnoreCase)).ToList();
if (!string.IsNullOrWhiteSpace(status) && Enum.TryParse<CreditMemoStatus>(status, out var parsed))
memos = memos.Where(m => m.Status == parsed).ToList();
ViewBag.Status = status ?? "";
ViewBag.Search = search ?? "";
ViewBag.ActiveCount = memos.Count(m => m.Status is CreditMemoStatus.Active or CreditMemoStatus.PartiallyApplied);
ViewBag.OutstandingBalance = memos
.Where(m => m.Status is not CreditMemoStatus.Voided and not CreditMemoStatus.FullyApplied)
.Sum(m => m.RemainingBalance);
return View(memos.OrderByDescending(m => m.IssueDate).ToList());
}
/// <summary>Shows a single credit memo with its full application history and an Apply modal for open invoices.</summary>
[HttpGet]
public async Task<IActionResult> Details(int id)
{
var memo = await _unitOfWork.CreditMemos.GetByIdAsync(
id, false,
m => m.Customer,
m => m.OriginalInvoice,
m => m.IssuedBy);
if (memo == null) return NotFound();
var applications = await _unitOfWork.CreditMemoApplications.FindAsync(
a => a.CreditMemoId == id, false,
a => a.Invoice,
a => a.AppliedBy);
var openInvoices = await _unitOfWork.Invoices.FindAsync(
i => i.CustomerId == memo.CustomerId
&& i.Status != InvoiceStatus.Paid
&& i.Status != InvoiceStatus.Voided
&& i.Status != InvoiceStatus.WrittenOff);
ViewBag.Applications = applications.OrderByDescending(a => a.AppliedDate).ToList();
ViewBag.OpenInvoices = openInvoices.Where(i => i.BalanceDue > 0).OrderBy(i => i.DueDate).ToList();
ViewBag.CanApply = memo.Status is CreditMemoStatus.Active or CreditMemoStatus.PartiallyApplied
&& memo.RemainingBalance > 0;
return View(memo);
}
/// <summary>Shows the standalone credit-memo creation form. Accepts optional customerId/invoiceId query params to pre-populate.</summary>
[HttpGet]
public async Task<IActionResult> Create(int? customerId, int? invoiceId)
{
string? linkedInvoiceNumber = null;
if (invoiceId.HasValue)
{
var inv = await _unitOfWork.Invoices.GetByIdAsync(invoiceId.Value);
if (inv != null)
{
linkedInvoiceNumber = inv.InvoiceNumber;
customerId ??= inv.CustomerId;
}
}
await PopulateCustomersAsync(customerId);
ViewBag.LinkedInvoiceNumber = linkedInvoiceNumber;
return View(new CreditMemoCreateVm
{
CustomerId = customerId ?? 0,
OriginalInvoiceId = invoiceId
});
}
/// <summary>
/// Creates a standalone credit memo and immediately increments customer.CreditBalance so the
/// credit is visible on the customer account before it is applied to any specific invoice.
/// </summary>
[HttpPost, ValidateAntiForgeryToken]
public async Task<IActionResult> Create(CreditMemoCreateVm vm)
{
if (!ModelState.IsValid)
{
await PopulateCustomersAsync(vm.CustomerId);
return View(vm);
}
var customer = await _unitOfWork.Customers.GetByIdAsync(vm.CustomerId);
if (customer == null)
{
ModelState.AddModelError("CustomerId", "Customer not found.");
await PopulateCustomersAsync(null);
return View(vm);
}
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var memoNumber = await GenerateMemoNumberAsync(companyId);
var currentUser = await _userManager.GetUserAsync(User);
var memo = new CreditMemo
{
MemoNumber = memoNumber,
CustomerId = vm.CustomerId,
OriginalInvoiceId = vm.OriginalInvoiceId > 0 ? vm.OriginalInvoiceId : null,
Amount = vm.Amount,
AmountApplied = 0,
IssueDate = DateTime.UtcNow,
ExpiryDate = vm.ExpiryDate.HasValue
? DateTime.SpecifyKind(vm.ExpiryDate.Value, DateTimeKind.Utc)
: null,
Reason = vm.Reason,
Notes = vm.Notes,
Status = CreditMemoStatus.Active,
IssuedById = currentUser?.Id,
CompanyId = companyId,
CreatedAt = DateTime.UtcNow
};
await _unitOfWork.CreditMemos.AddAsync(memo);
customer.CreditBalance += vm.Amount;
await _unitOfWork.Customers.UpdateAsync(customer);
await _unitOfWork.CompleteAsync();
TempData["Success"] = $"Credit memo {memoNumber} for {vm.Amount:C} issued to {DisplayName(customer)}.";
return RedirectToAction(nameof(Details), new { id = memo.Id });
}
/// <summary>
/// Applies a portion of this credit memo to an open invoice. The applied amount is capped at the
/// minimum of the requested amount, the memo's RemainingBalance, and the invoice's BalanceDue —
/// preventing over-application even with concurrent requests. Customer.CreditBalance is reduced
/// by the same applied amount. Automatically marks the invoice Paid when BalanceDue reaches zero.
/// </summary>
[HttpPost, ValidateAntiForgeryToken]
public async Task<IActionResult> Apply(int id, int invoiceId, decimal amount)
{
try
{
var memo = await _unitOfWork.CreditMemos.GetByIdAsync(id);
if (memo == null) return NotFound();
var invoice = await _unitOfWork.Invoices.GetByIdAsync(invoiceId, false, i => i.Customer);
if (invoice == null)
{
TempData["Error"] = "Invoice not found.";
return RedirectToAction(nameof(Details), new { id });
}
if (memo.Status is CreditMemoStatus.Voided or CreditMemoStatus.FullyApplied)
{
TempData["Error"] = "Credit memo is not available to apply.";
return RedirectToAction(nameof(Details), new { id });
}
var applyAmount = Math.Min(amount, Math.Min(memo.RemainingBalance, invoice.BalanceDue));
if (applyAmount <= 0)
{
TempData["Error"] = "No applicable amount — invoice may already be paid or credit exhausted.";
return RedirectToAction(nameof(Details), new { id });
}
var currentUser = await _userManager.GetUserAsync(User);
await _unitOfWork.ExecuteInTransactionAsync(async () =>
{
await _unitOfWork.CreditMemoApplications.AddAsync(new CreditMemoApplication
{
CreditMemoId = id,
InvoiceId = invoiceId,
AmountApplied = applyAmount,
AppliedDate = DateTime.UtcNow,
AppliedById = currentUser?.Id,
CompanyId = invoice.CompanyId,
CreatedAt = DateTime.UtcNow
});
invoice.CreditApplied += applyAmount;
await _unitOfWork.Invoices.UpdateAsync(invoice);
memo.AmountApplied += applyAmount;
memo.Status = memo.AmountApplied >= memo.Amount
? CreditMemoStatus.FullyApplied
: CreditMemoStatus.PartiallyApplied;
await _unitOfWork.CreditMemos.UpdateAsync(memo);
if (invoice.Customer != null)
{
invoice.Customer.CreditBalance = Math.Max(0, invoice.Customer.CreditBalance - applyAmount);
await _unitOfWork.Customers.UpdateAsync(invoice.Customer);
}
if (invoice.BalanceDue <= 0 && invoice.Status != InvoiceStatus.Paid)
{
invoice.Status = InvoiceStatus.Paid;
invoice.PaidDate = DateTime.UtcNow;
await _unitOfWork.Invoices.UpdateAsync(invoice);
}
// GL: DR 4950 Sales Discounts (contra-revenue) / CR AR.
// The dynamic report computation attributes credit memo applications to both
// accounts already; this call keeps Account.CurrentBalance in sync for
// RecalculateAllAsync and any tools that read it directly.
var arAcct = await _unitOfWork.Accounts.FirstOrDefaultAsync(
a => a.AccountSubType == AccountSubType.AccountsReceivable && a.IsActive);
var discountAcct = await _unitOfWork.Accounts.FirstOrDefaultAsync(
a => a.AccountNumber == "4950" && a.IsActive)
?? await _unitOfWork.Accounts.FirstOrDefaultAsync(
a => a.AccountType == AccountType.Revenue && a.IsActive
&& a.Name.ToLower().Contains("discount"));
await _accountBalanceService.DebitAsync(discountAcct?.Id, applyAmount);
await _accountBalanceService.CreditAsync(arAcct?.Id, applyAmount);
await _unitOfWork.CompleteAsync();
});
TempData["Success"] = $"{applyAmount:C} applied to invoice {invoice.InvoiceNumber}.";
}
catch (Exception ex)
{
_logger.LogError(ex, "Error applying credit memo {MemoId} to invoice {InvoiceId}", id, invoiceId);
TempData["Error"] = "An error occurred applying the credit.";
}
return RedirectToAction(nameof(Details), new { id });
}
/// <summary>
/// Voids a credit memo and reverses only the unapplied remainder from customer.CreditBalance.
/// The portion already applied to invoices is NOT reversed — those reductions to BalanceDue are
/// settled and form part of the immutable audit trail.
/// </summary>
[HttpPost, ValidateAntiForgeryToken]
public async Task<IActionResult> Void(int id)
{
var memo = await _unitOfWork.CreditMemos.GetByIdAsync(id, false, m => m.Customer);
if (memo == null) return NotFound();
if (memo.Status == CreditMemoStatus.Voided)
{
TempData["Error"] = "Credit memo is already voided.";
return RedirectToAction(nameof(Details), new { id });
}
var remaining = memo.Amount - memo.AmountApplied;
memo.Status = CreditMemoStatus.Voided;
memo.UpdatedAt = DateTime.UtcNow;
await _unitOfWork.CreditMemos.UpdateAsync(memo);
if (remaining > 0 && memo.Customer != null)
{
memo.Customer.CreditBalance = Math.Max(0, memo.Customer.CreditBalance - remaining);
await _unitOfWork.Customers.UpdateAsync(memo.Customer);
}
await _unitOfWork.CompleteAsync();
TempData["Success"] = "Credit memo voided. Unapplied balance reversed from customer credit.";
return RedirectToAction(nameof(Details), new { id });
}
private async Task PopulateCustomersAsync(int? selectedId)
{
var customers = await _unitOfWork.Customers.GetAllAsync();
ViewBag.Customers = customers
.OrderBy(c => c.CompanyName ?? $"{c.ContactFirstName} {c.ContactLastName}".Trim())
.Select(c => new SelectListItem
{
Value = c.Id.ToString(),
Text = c.IsTaxExempt ? $"{DisplayName(c)} ★" : DisplayName(c),
Selected = c.Id == selectedId
})
.ToList();
}
/// <summary>
/// Generates the next sequential memo number in CM-YYMM-#### format.
/// Uses IgnoreQueryFilters so soft-deleted memos count, preventing number reuse.
/// </summary>
private async Task<string> GenerateMemoNumberAsync(int companyId)
{
var prefix = $"CM-{DateTime.UtcNow:yy}{DateTime.UtcNow.Month:D2}-";
var existing = (await _unitOfWork.CreditMemos.FindAsync(
m => m.CompanyId == companyId && m.MemoNumber.StartsWith(prefix), true))
.Select(m => m.MemoNumber)
.ToList();
var maxNum = 0;
foreach (var num in existing)
{
var suffix = num.Length >= prefix.Length + 4 ? num[prefix.Length..] : "";
if (int.TryParse(suffix, out int n) && n > maxNum)
maxNum = n;
}
return $"{prefix}{(maxNum + 1):D4}";
}
private static string DisplayName(Customer? c) =>
c == null ? string.Empty :
!string.IsNullOrWhiteSpace(c.CompanyName) ? c.CompanyName
: $"{c.ContactFirstName} {c.ContactLastName}".Trim();
}
public class CreditMemoCreateVm
{
[Required, Range(1, int.MaxValue, ErrorMessage = "Please select a customer.")]
public int CustomerId { get; set; }
[Required, Range(0.01, 1_000_000, ErrorMessage = "Amount must be greater than $0.00.")]
public decimal Amount { get; set; }
[Required, MaxLength(500, ErrorMessage = "Reason cannot exceed 500 characters.")]
public string Reason { get; set; } = string.Empty;
[MaxLength(2000)]
public string? Notes { get; set; }
public DateTime? ExpiryDate { get; set; }
/// <summary>Optional link to the invoice that prompted this credit (price dispute, billing error, etc.).</summary>
public int? OriginalInvoiceId { get; set; }
}
@@ -26,6 +26,7 @@ public class CustomersController : Controller
private readonly ISubscriptionService _subscriptionService;
private readonly ITenantContext _tenantContext;
private readonly UserManager<ApplicationUser> _userManager;
private readonly IFinancialReportService _financialReports;
public CustomersController(
IUnitOfWork unitOfWork,
@@ -34,7 +35,8 @@ public class CustomersController : Controller
INotificationService notificationService,
ISubscriptionService subscriptionService,
ITenantContext tenantContext,
UserManager<ApplicationUser> userManager)
UserManager<ApplicationUser> userManager,
IFinancialReportService financialReports)
{
_unitOfWork = unitOfWork;
_mapper = mapper;
@@ -43,6 +45,7 @@ public class CustomersController : Controller
_subscriptionService = subscriptionService;
_tenantContext = tenantContext;
_userManager = userManager;
_financialReports = financialReports;
}
/// <summary>
@@ -935,6 +938,30 @@ public class CustomersController : Controller
return RedirectToAction(nameof(Details), new { id });
}
/// <summary>
/// Displays or downloads a dated activity statement for a customer.
/// Pass <c>pdf=true</c> to download the QuestPDF version; otherwise renders the HTML view.
/// </summary>
[HttpGet]
public async Task<IActionResult> Statement(int id, DateTime? from, DateTime? to, bool pdf = false)
{
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var fromDate = from ?? new DateTime(DateTime.Today.Year, DateTime.Today.Month, 1);
var toDate = to ?? DateTime.Today;
var dto = await _financialReports.GetCustomerStatementAsync(companyId, id, fromDate, toDate);
if (pdf)
{
var bytes = StatementPdfHelper.Generate(
dto.CustomerName, dto.CompanyName, dto.CustomerAddress,
dto.From, dto.To, dto.OpeningBalance, dto.Lines, dto.ClosingBalance, isVendor: false);
return File(bytes, "application/pdf", $"Statement-{dto.CustomerName}-{toDate:yyyyMMdd}.pdf");
}
return View(dto);
}
/// <summary>
/// Generates the next sequential credit memo number in CM-YYMM-#### format.
/// Uses <c>ignoreQueryFilters: true</c> when scanning all existing memos so that

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