Compare commits

..

41 Commits

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Migration AddBudgetsAndYearEndClose applied

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-09 22:12:33 -04:00
322 changed files with 186849 additions and 3394 deletions
@@ -322,3 +322,214 @@ public class ClaudeAnomalyFlag
public string? RecommendedAction { get; set; } public string? RecommendedAction { get; set; }
public string? BillNumber { get; set; } public string? BillNumber { get; set; }
} }
// ── Feature 7: Bank Rec Auto-Match ───────────────────────────────────────────
public class BankRecMatchItem
{
public string EntityType { get; set; } = string.Empty; // "Payment", "BillPayment", "Expense"
public int EntityId { get; set; }
public string Date { get; set; } = string.Empty; // ISO 8601
public string Reference { get; set; } = string.Empty;
public string Description { get; set; } = string.Empty;
public decimal Amount { get; set; }
public string Direction { get; set; } = string.Empty; // "deposit" or "payment"
}
public class AutoMatchRequest
{
public List<BankRecMatchItem> UnclearedItems { get; set; } = new();
public decimal BeginningBalance { get; set; }
public decimal StatementEndingBalance { get; set; }
}
public class AutoMatchSuggestion
{
public string EntityType { get; set; } = string.Empty;
public int EntityId { get; set; }
public double Confidence { get; set; } // 0.01.0
public string Reason { get; set; } = string.Empty;
}
public class AutoMatchResult
{
public bool Success { get; set; }
public string? ErrorMessage { get; set; }
public List<AutoMatchSuggestion> SuggestedCleared { get; set; } = new();
public List<string> Insights { get; set; } = new();
}
/// <summary>Internal JSON schema that Claude returns for bank rec auto-match.</summary>
public class ClaudeAutoMatchResponse
{
public List<ClaudeAutoMatchSuggestion> SuggestedCleared { get; set; } = new();
public List<string> Insights { get; set; } = new();
}
public class ClaudeAutoMatchSuggestion
{
public string EntityType { get; set; } = string.Empty;
public int EntityId { get; set; }
public double Confidence { get; set; }
public string Reason { get; set; } = string.Empty;
}
// ── Feature 8: Late Payment Prediction ───────────────────────────────────────
public class OpenInvoiceSummary
{
public string InvoiceNumber { get; set; } = string.Empty;
public decimal BalanceDue { get; set; }
public string? DueDateIso { get; set; }
public int DaysOverdue { get; set; }
}
public class LatePaymentCustomerData
{
public string CustomerName { get; set; } = string.Empty;
public decimal TotalOwed { get; set; }
public double AvgDaysToPay { get; set; } // historical average
public int TotalInvoicesAllTime { get; set; }
public int LateInvoicesAllTime { get; set; }
public List<OpenInvoiceSummary> OpenInvoices { get; set; } = new();
}
public class LatePaymentPredictionRequest
{
public string CompanyName { get; set; } = string.Empty;
public List<LatePaymentCustomerData> Customers { get; set; } = new();
}
public class LatePaymentPrediction
{
public string CustomerName { get; set; } = string.Empty;
/// <summary>"high", "medium", or "low"</summary>
public string RiskLevel { get; set; } = "medium";
public int EstimatedDaysToPayment { get; set; }
public string Reasoning { get; set; } = string.Empty;
}
public class LatePaymentPredictionResult
{
public bool Success { get; set; }
public string? ErrorMessage { get; set; }
public List<LatePaymentPrediction> Predictions { get; set; } = new();
public List<string> Insights { get; set; } = new();
}
/// <summary>Internal JSON schema that Claude returns for late payment predictions.</summary>
public class ClaudeLatePaymentResponse
{
public List<ClaudeLatePaymentPrediction> Predictions { get; set; } = new();
public List<string> Insights { get; set; } = new();
}
public class ClaudeLatePaymentPrediction
{
public string CustomerName { get; set; } = string.Empty;
public string RiskLevel { get; set; } = "medium";
public int EstimatedDaysToPayment { get; set; }
public string Reasoning { get; set; } = string.Empty;
}
// ── Feature 9: Natural Language Financial Queries ─────────────────────────────
public class MonthlyFinancialSummary
{
public string Month { get; set; } = string.Empty; // "YYYY-MM"
public decimal Revenue { get; set; }
public decimal Expenses { get; set; }
public decimal NetIncome { get; set; }
}
public class FinancialQueryContext
{
public string CompanyName { get; set; } = string.Empty;
public string AsOfDate { get; set; } = string.Empty;
public decimal TotalRevenueYtd { get; set; }
public decimal TotalExpensesYtd { get; set; }
public decimal NetIncomeYtd { get; set; }
public decimal ArOutstanding { get; set; }
public decimal ApOutstanding { get; set; }
public List<MonthlyFinancialSummary> Last12Months { get; set; } = new();
public List<ExpenseByCategory> ExpensesByCategory { get; set; } = new();
}
public class FinancialQueryRequest
{
public string Question { get; set; } = string.Empty;
public FinancialQueryContext Context { get; set; } = new();
}
public class FinancialQueryResult
{
public bool Success { get; set; }
public string? ErrorMessage { get; set; }
public string Answer { get; set; } = string.Empty;
public string? FollowUpSuggestion { get; set; }
public List<string> RelevantFacts { get; set; } = new();
}
/// <summary>Internal JSON schema that Claude returns for financial queries.</summary>
public class ClaudeFinancialQueryResponse
{
public string Answer { get; set; } = string.Empty;
public string? FollowUpSuggestion { get; set; }
public List<string> RelevantFacts { get; set; } = new();
}
// ── Feature 10: Recurring Bill Detection ─────────────────────────────────────
public class RecurringBillHistoryItem
{
public string VendorName { get; set; } = string.Empty;
public string BillNumber { get; set; } = string.Empty;
public decimal Amount { get; set; }
public string DateIso { get; set; } = string.Empty;
public string? Memo { get; set; }
}
public class RecurringBillDetectionRequest
{
public string CompanyName { get; set; } = string.Empty;
public List<RecurringBillHistoryItem> Bills { get; set; } = new();
}
public class RecurringBillPattern
{
public string VendorName { get; set; } = string.Empty;
/// <summary>"monthly", "quarterly", "biannual", "annual"</summary>
public string Frequency { get; set; } = string.Empty;
public decimal TypicalAmount { get; set; }
public string? NextExpectedDateIso { get; set; }
/// <summary>"high", "medium", or "low"</summary>
public string Confidence { get; set; } = "medium";
public string Description { get; set; } = string.Empty;
public string? SuggestedAction { get; set; }
}
public class RecurringBillDetectionResult
{
public bool Success { get; set; }
public string? ErrorMessage { get; set; }
public List<RecurringBillPattern> Patterns { get; set; } = new();
public List<string> Insights { get; set; } = new();
}
/// <summary>Internal JSON schema that Claude returns for recurring bill detection.</summary>
public class ClaudeRecurringBillResponse
{
public List<ClaudeRecurringPattern> Patterns { get; set; } = new();
public List<string> Insights { get; set; } = new();
}
public class ClaudeRecurringPattern
{
public string VendorName { get; set; } = string.Empty;
public string Frequency { get; set; } = string.Empty;
public decimal TypicalAmount { get; set; }
public string? NextExpectedDateIso { get; set; }
public string Confidence { get; set; } = "medium";
public string Description { get; set; } = string.Empty;
public string? SuggestedAction { get; set; }
}
@@ -2,6 +2,158 @@ using PowderCoating.Core.Enums;
namespace PowderCoating.Application.DTOs.Accounting; namespace PowderCoating.Application.DTOs.Accounting;
// Accounting method badge — set on report DTOs so views can show "Cash Basis" / "Accrual Basis"
// without needing a separate round-trip to the company settings.
// ── Cash Flow Statement ──────────────────────────────────────────────────────
/// <summary>
/// Cash Flow Statement using the direct (cash-basis) method for operating activities.
/// Investing and Financing sections contain line items derived from account-level changes.
/// BeginningCash + NetChangeInCash should equal EndingCash (within rounding tolerances).
/// </summary>
public class CashFlowStatementDto
{
public string CompanyName { get; set; } = string.Empty;
public DateTime From { get; set; }
public DateTime To { get; set; }
public AccountingMethod Method { get; set; }
// ── Operating (direct / cash method) ───────────────────────────────────
/// <summary>Customer invoice payments received in the period.</summary>
public decimal CashFromCustomers { get; set; }
/// <summary>Vendor bill payments made in the period.</summary>
public decimal CashToVendors { get; set; }
/// <summary>Direct expense payments made in the period (not via bills).</summary>
public decimal CashForExpenses { get; set; }
public decimal NetOperating => CashFromCustomers - CashToVendors - CashForExpenses;
// ── Investing ──────────────────────────────────────────────────────────
public List<CashFlowLineDto> InvestingLines { get; set; } = new();
public decimal NetInvesting => InvestingLines.Sum(l => l.Amount);
// ── Financing ──────────────────────────────────────────────────────────
public List<CashFlowLineDto> FinancingLines { get; set; } = new();
public decimal NetFinancing => FinancingLines.Sum(l => l.Amount);
// ── Summary ────────────────────────────────────────────────────────────
public decimal BeginningCash { get; set; }
public decimal NetChangeInCash => NetOperating + NetInvesting + NetFinancing;
public decimal EndingCash => BeginningCash + NetChangeInCash;
}
/// <summary>A single line in the Investing or Financing section of the Cash Flow Statement.</summary>
public class CashFlowLineDto
{
public string Label { get; set; } = string.Empty;
/// <summary>Positive = cash inflow, negative = cash outflow.</summary>
public decimal Amount { get; set; }
}
// ── Customer / Vendor Statements ─────────────────────────────────────────────
public class CustomerStatementDto
{
public int CustomerId { get; set; }
public string CustomerName { get; set; } = string.Empty;
public string CompanyName { get; set; } = string.Empty;
public string? CustomerAddress { get; set; }
public DateTime From { get; set; }
public DateTime To { get; set; }
public decimal OpeningBalance { get; set; }
public List<StatementLineDto> Lines { get; set; } = new();
public decimal ClosingBalance { get; set; }
}
public class VendorStatementDto
{
public int VendorId { get; set; }
public string VendorName { get; set; } = string.Empty;
public string CompanyName { get; set; } = string.Empty;
public DateTime From { get; set; }
public DateTime To { get; set; }
public decimal OpeningBalance { get; set; }
public List<StatementLineDto> Lines { get; set; } = new();
public decimal ClosingBalance { get; set; }
}
public class StatementLineDto
{
public DateTime Date { get; set; }
/// <summary>E.g., "Invoice", "Payment", "Credit Applied", "Deposit Applied".</summary>
public string Type { get; set; } = string.Empty;
public string Reference { get; set; } = string.Empty;
public string Description { get; set; } = string.Empty;
/// <summary>Amount added to the balance (invoice for customer, bill for vendor).</summary>
public decimal? Debit { get; set; }
/// <summary>Amount reducing the balance (payment, credit).</summary>
public decimal? Credit { get; set; }
public decimal RunningBalance { get; set; }
}
// ── AP Aging ──────────────────────────────────────────────────────────────────
public class ApAgingReportDto
{
public DateTime AsOf { get; set; }
public string CompanyName { get; set; } = string.Empty;
public List<ApAgingVendorDto> Vendors { get; set; } = new();
public decimal TotalCurrent { get; set; }
public decimal Total1to30 { get; set; }
public decimal Total31to60 { get; set; }
public decimal Total61to90 { get; set; }
public decimal TotalOver90 { get; set; }
public decimal TotalOutstanding => TotalCurrent + Total1to30 + Total31to60 + Total61to90 + TotalOver90;
}
public class ApAgingVendorDto
{
public int VendorId { get; set; }
public string VendorName { get; set; } = string.Empty;
public List<ApAgingBillDto> Bills { get; set; } = new();
public decimal TotalCurrent { get; set; }
public decimal Total1to30 { get; set; }
public decimal Total31to60 { get; set; }
public decimal Total61to90 { get; set; }
public decimal TotalOver90 { get; set; }
public decimal TotalBalance => TotalCurrent + Total1to30 + Total31to60 + Total61to90 + TotalOver90;
}
public class ApAgingBillDto
{
public int BillId { get; set; }
public string BillNumber { get; set; } = string.Empty;
public DateTime BillDate { get; set; }
public DateTime? DueDate { get; set; }
public decimal BalanceDue { get; set; }
public int DaysOverdue { get; set; }
}
// ── Trial Balance ─────────────────────────────────────────────────────────────
public class TrialBalanceDto
{
public DateTime AsOf { get; set; }
public string CompanyName { get; set; } = string.Empty;
public List<TrialBalanceLine> Lines { get; set; } = new();
public decimal TotalDebits { get; set; }
public decimal TotalCredits { get; set; }
public bool IsBalanced => Math.Abs(TotalDebits - TotalCredits) < 0.01m;
}
public class TrialBalanceLine
{
public int AccountId { get; set; }
public string AccountNumber { get; set; } = string.Empty;
public string AccountName { get; set; } = string.Empty;
public AccountType AccountType { get; set; }
public decimal DebitBalance { get; set; }
public decimal CreditBalance { get; set; }
}
// ── Profit & Loss ───────────────────────────────────────────────────────────── // ── Profit & Loss ─────────────────────────────────────────────────────────────
public class ProfitAndLossDto public class ProfitAndLossDto
@@ -9,6 +161,7 @@ public class ProfitAndLossDto
public DateTime From { get; set; } public DateTime From { get; set; }
public DateTime To { get; set; } public DateTime To { get; set; }
public string CompanyName { get; set; } = string.Empty; public string CompanyName { get; set; } = string.Empty;
public AccountingMethod AccountingMethod { get; set; } = AccountingMethod.Accrual;
public List<FinancialReportLine> RevenueLines { get; set; } = new(); public List<FinancialReportLine> RevenueLines { get; set; } = new();
public decimal TotalRevenue { get; set; } public decimal TotalRevenue { get; set; }
@@ -40,6 +193,7 @@ public class BalanceSheetDto
{ {
public DateTime AsOf { get; set; } public DateTime AsOf { get; set; }
public string CompanyName { get; set; } = string.Empty; public string CompanyName { get; set; } = string.Empty;
public AccountingMethod AccountingMethod { get; set; } = AccountingMethod.Accrual;
// Assets // Assets
public List<FinancialReportLine> CurrentAssets { get; set; } = new(); public List<FinancialReportLine> CurrentAssets { get; set; } = new();
@@ -3,6 +3,22 @@ namespace PowderCoating.Application.DTOs.Common;
public class PagedResult<T> public class PagedResult<T>
{ {
public IEnumerable<T> Items { get; set; } = new List<T>(); public IEnumerable<T> Items { get; set; } = new List<T>();
/// <summary>
/// Creates a PagedResult populated from a GridRequest, avoiding repetitive property
/// assignments across every Index action. SortColumn, SortDirection, and SearchTerm
/// are copied from the grid so the model carries full state for view binding.
/// </summary>
public static PagedResult<T> From(GridRequest grid, IEnumerable<T> items, int totalCount) => new()
{
Items = items,
PageNumber = grid.PageNumber,
PageSize = grid.PageSize,
TotalCount = totalCount,
SortColumn = grid.SortColumn,
SortDirection = grid.SortDirection,
SearchTerm = grid.SearchTerm
};
public int PageNumber { get; set; } public int PageNumber { get; set; }
public int PageSize { get; set; } public int PageSize { get; set; }
public int TotalCount { get; set; } public int TotalCount { get; set; }
@@ -71,6 +71,11 @@ public class CompanyListDto
public bool WizardCompleted { get; set; } public bool WizardCompleted { get; set; }
public DateTime? WizardCompletedAt { get; set; } public DateTime? WizardCompletedAt { get; set; }
public string? WizardCompletedByName { 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> /// <summary>
@@ -21,6 +21,7 @@ namespace PowderCoating.Application.DTOs.Company
public string? State { get; set; } public string? State { get; set; }
public string? ZipCode { get; set; } public string? ZipCode { get; set; }
public string? TimeZone { get; set; } public string? TimeZone { get; set; }
public AccountingMethod AccountingMethod { get; set; } = AccountingMethod.Accrual;
public bool HasLogo { get; set; } public bool HasLogo { get; set; }
public CompanyOperatingCostsDto? OperatingCosts { get; set; } public CompanyOperatingCostsDto? OperatingCosts { get; set; }
@@ -96,6 +97,9 @@ namespace PowderCoating.Application.DTOs.Company
[StringLength(50, ErrorMessage = "Time zone cannot exceed 50 characters")] [StringLength(50, ErrorMessage = "Time zone cannot exceed 50 characters")]
public string? TimeZone { get; set; } public string? TimeZone { get; set; }
/// <summary>Cash or Accrual accounting method preference for financial reports.</summary>
public AccountingMethod AccountingMethod { get; set; } = AccountingMethod.Accrual;
} }
/// <summary> /// <summary>
@@ -140,12 +140,12 @@ public class CreateCustomerDto : IValidatableObject
new[] { nameof(CompanyName), nameof(ContactFirstName), nameof(ContactLastName) }); new[] { nameof(CompanyName), nameof(ContactFirstName), nameof(ContactLastName) });
} }
// At least one contact method is required (Email OR Phone) // At least one contact method is required (Email, Phone, or Mobile Phone)
if (string.IsNullOrWhiteSpace(Email) && string.IsNullOrWhiteSpace(Phone)) if (string.IsNullOrWhiteSpace(Email) && string.IsNullOrWhiteSpace(Phone) && string.IsNullOrWhiteSpace(MobilePhone))
{ {
yield return new ValidationResult( yield return new ValidationResult(
"Please provide at least one contact method (Email or Phone)", "Please provide at least one contact method (Email, Phone, or Mobile Phone)",
new[] { nameof(Email), nameof(Phone) }); new[] { nameof(Email), nameof(Phone), nameof(MobilePhone) });
} }
// Validate each address in comma-separated email fields // Validate each address in comma-separated email fields
@@ -82,6 +82,10 @@ public class CreateInvoiceDto
public string? InternalNotes { get; set; } public string? InternalNotes { get; set; }
public string? Terms { get; set; } public string? Terms { get; set; }
public string? CustomerPO { 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(); public List<CreateInvoiceItemDto> InvoiceItems { get; set; } = new();
} }
@@ -36,6 +36,8 @@ public class IssueRefundDto
public decimal Amount { get; set; } public decimal Amount { get; set; }
public DateTime RefundDate { get; set; } = DateTime.Today; public DateTime RefundDate { get; set; } = DateTime.Today;
public PaymentMethod RefundMethod { get; set; } 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 Reason { get; set; } = string.Empty;
public string? Reference { get; set; } public string? Reference { get; set; }
public string? Notes { get; set; } public string? Notes { get; set; }
@@ -41,6 +41,8 @@ public class CompanyUserDto
public bool CanManageMaintenance { get; set; } public bool CanManageMaintenance { get; set; }
public bool CanManageInvoices { get; set; } public bool CanManageInvoices { get; set; }
public bool CanViewReports { get; set; } public bool CanViewReports { get; set; }
public bool CanManageBills { get; set; }
public bool CanManageAccounting { get; set; }
} }
/// <summary> /// <summary>
@@ -156,6 +158,12 @@ public class CreateCompanyUserDto
[Display(Name = "Can View Reports")] [Display(Name = "Can View Reports")]
public bool CanViewReports { get; set; } 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")] [Display(Name = "Send Welcome Email")]
public bool SendWelcomeEmail { get; set; } = true; public bool SendWelcomeEmail { get; set; } = true;
} }
@@ -258,4 +266,10 @@ public class UpdateCompanyUserDto
[Display(Name = "Can View Reports")] [Display(Name = "Can View Reports")]
public bool CanViewReports { get; set; } 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")] [Display(Name = "Preferred Vendor")]
public bool IsPreferred { get; set; } = false; public bool IsPreferred { get; set; } = false;
[Display(Name = "1099 Vendor")]
public bool Is1099Vendor { get; set; } = false;
[Display(Name = "Default Expense Account")] [Display(Name = "Default Expense Account")]
public int? DefaultExpenseAccountId { get; set; } public int? DefaultExpenseAccountId { get; set; }
} }
@@ -201,6 +204,9 @@ public class UpdateVendorDto
[Display(Name = "Preferred Vendor")] [Display(Name = "Preferred Vendor")]
public bool IsPreferred { get; set; } public bool IsPreferred { get; set; }
[Display(Name = "1099 Vendor")]
public bool Is1099Vendor { get; set; }
[Display(Name = "Default Expense Account")] [Display(Name = "Default Expense Account")]
public int? DefaultExpenseAccountId { get; set; } public int? DefaultExpenseAccountId { get; set; }
} }
@@ -43,4 +43,33 @@ public interface IAccountingAiService
/// Returns a ranked list of flagged items with recommended actions. /// Returns a ranked list of flagged items with recommended actions.
/// </summary> /// </summary>
Task<AnomalyDetectionResult> DetectAnomaliesAsync(AnomalyDetectionRequest request); 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);
} }
@@ -1,4 +1,5 @@
using PowderCoating.Application.DTOs.Accounting; using PowderCoating.Application.DTOs.Accounting;
using PowderCoating.Core.Enums;
namespace PowderCoating.Application.Interfaces; namespace PowderCoating.Application.Interfaces;
@@ -6,14 +7,16 @@ namespace PowderCoating.Application.Interfaces;
/// Read-only service for financial aggregate reports. All methods query the database /// Read-only service for financial aggregate reports. All methods query the database
/// with AsNoTracking and return pre-shaped DTOs — no tracked entities are returned. /// with AsNoTracking and return pre-shaped DTOs — no tracked entities are returned.
/// Implemented in Infrastructure; uses ApplicationDbContext directly. /// Implemented in Infrastructure; uses ApplicationDbContext directly.
/// The <paramref name="method"/> parameter overrides the company's stored preference when
/// supplied; pass <c>null</c> to fall back to the company's configured accounting method.
/// </summary> /// </summary>
public interface IFinancialReportService public interface IFinancialReportService
{ {
/// <summary>Returns a Profit &amp; Loss report for the given company and date range.</summary> /// <summary>Returns a Profit &amp; Loss report for the given company and date range.</summary>
Task<ProfitAndLossDto> GetProfitAndLossAsync(int companyId, DateTime from, DateTime to); Task<ProfitAndLossDto> GetProfitAndLossAsync(int companyId, DateTime from, DateTime to, AccountingMethod? method = null);
/// <summary>Returns a Balance Sheet snapshot as of the given date.</summary> /// <summary>Returns a Balance Sheet snapshot as of the given date.</summary>
Task<BalanceSheetDto> GetBalanceSheetAsync(int companyId, DateTime asOf); Task<BalanceSheetDto> GetBalanceSheetAsync(int companyId, DateTime asOf, AccountingMethod? method = null);
/// <summary>Returns an AR Aging report bucketed at 0-30, 31-60, 61-90, and 90+ days.</summary> /// <summary>Returns an AR Aging report bucketed at 0-30, 31-60, 61-90, and 90+ days.</summary>
Task<ArAgingReportDto> GetArAgingAsync(int companyId, DateTime asOf); Task<ArAgingReportDto> GetArAgingAsync(int companyId, DateTime asOf);
@@ -23,4 +26,27 @@ public interface IFinancialReportService
/// <summary>Returns an invoice-basis Sales Tax Liability report for the given company and date range.</summary> /// <summary>Returns an invoice-basis Sales Tax Liability report for the given company and date range.</summary>
Task<SalesTaxReportDto> GetSalesTaxReportAsync(int companyId, DateTime from, DateTime to); Task<SalesTaxReportDto> GetSalesTaxReportAsync(int companyId, DateTime from, DateTime to);
/// <summary>Returns an AP Aging report bucketed at 0-30, 31-60, 61-90, and 90+ days past the bill due date.</summary>
Task<ApAgingReportDto> GetApAgingAsync(int companyId, DateTime asOf);
/// <summary>Returns a Trial Balance using current account balances as of the given date.</summary>
Task<TrialBalanceDto> GetTrialBalanceAsync(int companyId, DateTime asOf);
/// <summary>Looks up the accounting method configured for the given company. Returns Accrual if not found.</summary>
Task<AccountingMethod> GetCompanyAccountingMethodAsync(int companyId);
/// <summary>Returns a dated activity statement for a customer showing opening balance, all transactions in the period, and closing balance.</summary>
Task<CustomerStatementDto> GetCustomerStatementAsync(int companyId, int customerId, DateTime from, DateTime to);
/// <summary>Returns a dated activity statement for a vendor showing opening balance, all transactions in the period, and closing balance.</summary>
Task<VendorStatementDto> GetVendorStatementAsync(int companyId, int vendorId, DateTime from, DateTime to);
/// <summary>
/// Returns a Cash Flow Statement for the period using the direct (cash-basis) method for
/// operating activities. Investing and Financing sections are derived from account-level data.
/// BeginningCash is computed from all cash/bank account credits and debits prior to
/// <paramref name="from"/>; EndingCash adds the net change during the period.
/// </summary>
Task<CashFlowStatementDto> GetCashFlowStatementAsync(int companyId, DateTime from, DateTime to);
} }
@@ -0,0 +1,19 @@
using PowderCoating.Application.DTOs.Quote;
using PowderCoating.Core.Entities;
namespace PowderCoating.Application.Interfaces;
public interface IJobItemAssemblyService
{
JobItem CreateJobItem(CreateQuoteItemDto source, int jobId, int companyId, QuoteItemPricingResult pricing, DateTime createdAtUtc);
IReadOnlyList<JobItemCoat> CreateJobItemCoats(CreateQuoteItemDto source, int jobItemId, int companyId, DateTime createdAtUtc);
IReadOnlyList<JobItemPrepService> CreateJobItemPrepServices(CreateQuoteItemDto source, int jobItemId, int companyId, DateTime createdAtUtc);
JobItem CreateJobItem(QuoteItem source, int jobId, int companyId, DateTime createdAtUtc);
IReadOnlyList<JobItemCoat> CreateJobItemCoats(QuoteItem source, int jobItemId, int companyId, DateTime createdAtUtc);
IReadOnlyList<JobItemPrepService> CreateJobItemPrepServices(QuoteItem source, int jobItemId, int companyId, DateTime createdAtUtc);
JobItem CreateJobItem(JobItem source, int jobId, int companyId, DateTime createdAtUtc);
IReadOnlyList<JobItemCoat> CreateJobItemCoats(JobItem source, int jobItemId, int companyId, DateTime createdAtUtc);
IReadOnlyList<JobItemPrepService> CreateJobItemPrepServices(JobItem source, int jobItemId, int companyId, DateTime createdAtUtc);
}
@@ -42,6 +42,9 @@ public interface IPdfService
Task<byte[]> GenerateArAgingPdfAsync(ArAgingReportDto dto); Task<byte[]> GenerateArAgingPdfAsync(ArAgingReportDto dto);
Task<byte[]> GenerateSalesAndIncomePdfAsync(SalesIncomeReportDto dto); Task<byte[]> GenerateSalesAndIncomePdfAsync(SalesIncomeReportDto dto);
Task<byte[]> GenerateSalesTaxReportPdfAsync(SalesTaxReportDto dto); Task<byte[]> GenerateSalesTaxReportPdfAsync(SalesTaxReportDto dto);
Task<byte[]> GenerateApAgingPdfAsync(ApAgingReportDto dto);
Task<byte[]> GenerateTrialBalancePdfAsync(TrialBalanceDto dto);
Task<byte[]> GenerateCashFlowStatementPdfAsync(CashFlowStatementDto dto);
Task<byte[]> GenerateGiftCertificatePdfAsync( Task<byte[]> GenerateGiftCertificatePdfAsync(
GiftCertificateDto cert, GiftCertificateDto cert,
@@ -0,0 +1,16 @@
using PowderCoating.Application.DTOs.Quote;
using PowderCoating.Core.Entities;
namespace PowderCoating.Application.Interfaces;
public interface IQuotePricingAssemblyService
{
void ApplyPricingSnapshot(Quote quote, QuotePricingResult pricingResult);
Task<IReadOnlyList<QuoteItem>> CreateQuoteItemsAsync(
IEnumerable<CreateQuoteItemDto> itemDtos,
int quoteId,
int companyId,
decimal? ovenRateOverride,
DateTime createdAtUtc);
}
@@ -0,0 +1,59 @@
using Microsoft.AspNetCore.Http;
namespace PowderCoating.Application.Services;
/// <summary>
/// Shared file validation and content-type resolution used across all blob storage services.
/// </summary>
public static class BlobFileHelper
{
/// <summary>
/// Validates an uploaded file against an extension allowlist and a maximum size.
/// Returns the normalized (lowercase) extension on success so callers do not re-derive it.
/// </summary>
public static (bool IsValid, string Extension, string Error) ValidateUpload(
IFormFile? file,
string[] allowedExtensions,
long maxBytes)
{
if (file == null || file.Length == 0)
return (false, string.Empty, "No file provided.");
if (file.Length > maxBytes)
return (false, string.Empty, $"File exceeds the {maxBytes / 1024 / 1024} MB limit.");
var extension = Path.GetExtension(file.FileName).ToLowerInvariant();
if (string.IsNullOrEmpty(extension) || !allowedExtensions.Contains(extension))
return (false, string.Empty, $"File type not allowed. Allowed: {string.Join(", ", allowedExtensions)}.");
return (true, extension, string.Empty);
}
/// <summary>
/// Maps a file extension to its MIME content type, covering common image formats and
/// document types. Falls back to <c>application/octet-stream</c>.
/// </summary>
public static string GetContentType(string extension) => extension switch
{
".jpg" or ".jpeg" => "image/jpeg",
".png" => "image/png",
".gif" => "image/gif",
".webp" => "image/webp",
".svg" => "image/svg+xml",
".pdf" => "application/pdf",
".doc" => "application/msword",
".docx" => "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
".txt" => "text/plain",
_ => "application/octet-stream"
};
/// <summary>
/// Strips OS-invalid filename characters from a base filename (no extension), replacing
/// them with underscores to produce a safe blob path segment.
/// </summary>
public static string SanitizeFileName(string fileName)
{
var sanitized = string.Join("_", fileName.Split(Path.GetInvalidFileNameChars()));
return string.IsNullOrWhiteSpace(sanitized) ? "file" : sanitized;
}
}
@@ -47,15 +47,9 @@ public class CatalogImageService : ICatalogImageService
string? existingImagePath, string? existingImagePath,
string? existingThumbnailPath) string? existingThumbnailPath)
{ {
if (file == null || file.Length == 0) var (isValid, ext, validationError) = BlobFileHelper.ValidateUpload(file, AllowedExtensions, MaxFileSizeBytes);
return (false, string.Empty, string.Empty, "No file provided."); if (!isValid)
return (false, string.Empty, string.Empty, validationError);
if (file.Length > MaxFileSizeBytes)
return (false, string.Empty, string.Empty, "File exceeds the 10 MB limit.");
var ext = Path.GetExtension(file.FileName).ToLowerInvariant();
if (!AllowedExtensions.Contains(ext))
return (false, string.Empty, string.Empty, $"File type '{ext}' is not allowed. Accepted types: jpg, jpeg, png, gif, webp.");
var container = _settings.Containers.CatalogImages; var container = _settings.Containers.CatalogImages;
var blobId = Guid.NewGuid().ToString("N"); var blobId = Guid.NewGuid().ToString("N");
@@ -67,21 +67,15 @@ public class CompanyLogoService : ICompanyLogoService
/// </returns> /// </returns>
public async Task<(bool Success, string FilePath, string ErrorMessage)> SaveCompanyLogoAsync(IFormFile file, int companyId) public async Task<(bool Success, string FilePath, string ErrorMessage)> SaveCompanyLogoAsync(IFormFile file, int companyId)
{ {
if (file == null || file.Length == 0) var (isValid, extension, error) = BlobFileHelper.ValidateUpload(file, AllowedExtensions, MaxFileSize);
return (false, string.Empty, "No file provided"); if (!isValid)
return (false, string.Empty, error);
if (file.Length > MaxFileSize)
return (false, string.Empty, $"File size exceeds maximum allowed size of {MaxFileSize / 1024 / 1024} MB");
var extension = Path.GetExtension(file.FileName).ToLowerInvariant();
if (!AllowedExtensions.Contains(extension))
return (false, string.Empty, $"File type not allowed. Allowed types: {string.Join(", ", AllowedExtensions)}");
// Delete old logo (any extension) before saving new one // Delete old logo (any extension) before saving new one
await DeleteOldLogosAsync(companyId, extension); await DeleteOldLogosAsync(companyId, extension);
var blobName = GetCompanyLogoPath(companyId, extension); var blobName = GetCompanyLogoPath(companyId, extension);
var contentType = GetContentType(extension); var contentType = BlobFileHelper.GetContentType(extension);
using var stream = file.OpenReadStream(); using var stream = file.OpenReadStream();
var result = await _blobService.UploadAsync(_settings.Containers.CompanyLogos, blobName, stream, contentType); var result = await _blobService.UploadAsync(_settings.Containers.CompanyLogos, blobName, stream, contentType);
@@ -158,20 +152,4 @@ public class CompanyLogoService : ICompanyLogoService
} }
} }
/// <summary>
/// Maps a lowercase file extension to its canonical MIME content type.
/// The correct content type is required so that browsers render the image
/// inline rather than triggering a download.
/// </summary>
/// <param name="extension">Lowercase file extension including the leading dot.</param>
/// <returns>MIME type string, or <c>application/octet-stream</c> as a safe fallback.</returns>
private static string GetContentType(string extension) => extension switch
{
".jpg" or ".jpeg" => "image/jpeg",
".png" => "image/png",
".gif" => "image/gif",
".webp" => "image/webp",
".svg" => "image/svg+xml",
_ => "application/octet-stream"
};
} }
@@ -56,25 +56,16 @@ public class EquipmentManualService : IEquipmentManualService
/// </returns> /// </returns>
public async Task<(bool Success, string FilePath, string ErrorMessage)> SaveEquipmentManualAsync(IFormFile file, int companyId, int equipmentId) public async Task<(bool Success, string FilePath, string ErrorMessage)> SaveEquipmentManualAsync(IFormFile file, int companyId, int equipmentId)
{ {
if (file == null || file.Length == 0) var (isValid, extension, error) = BlobFileHelper.ValidateUpload(file, AllowedExtensions, MaxFileSize);
return (false, string.Empty, "No file provided"); if (!isValid)
return (false, string.Empty, error);
if (file.Length > MaxFileSize)
return (false, string.Empty, $"File size exceeds maximum allowed size of {MaxFileSize / 1024 / 1024} MB");
var extension = Path.GetExtension(file.FileName).ToLowerInvariant();
if (!AllowedExtensions.Contains(extension))
return (false, string.Empty, $"File type not allowed. Allowed types: {string.Join(", ", AllowedExtensions)}");
// Sanitize filename — replace OS-invalid characters with underscores to // Sanitize filename — replace OS-invalid characters with underscores to
// prevent path traversal and blob naming errors in Azure. // prevent path traversal and blob naming errors in Azure.
var fileName = Path.GetFileNameWithoutExtension(file.FileName); var fileName = BlobFileHelper.SanitizeFileName(Path.GetFileNameWithoutExtension(file.FileName));
fileName = string.Join("_", fileName.Split(Path.GetInvalidFileNameChars()));
if (string.IsNullOrWhiteSpace(fileName))
fileName = "manual";
var blobName = $"{companyId}/equipment-manuals/{equipmentId}/{fileName}{extension}"; var blobName = $"{companyId}/equipment-manuals/{equipmentId}/{fileName}{extension}";
var contentType = GetContentType(extension); var contentType = BlobFileHelper.GetContentType(extension);
using var stream = file.OpenReadStream(); using var stream = file.OpenReadStream();
var result = await _blobService.UploadAsync(_settings.Containers.Manuals, blobName, stream, contentType); var result = await _blobService.UploadAsync(_settings.Containers.Manuals, blobName, stream, contentType);
@@ -130,19 +121,4 @@ public class EquipmentManualService : IEquipmentManualService
return await _blobService.ExistsAsync(_settings.Containers.Manuals, filePath); return await _blobService.ExistsAsync(_settings.Containers.Manuals, filePath);
} }
/// <summary>
/// Maps a lowercase file extension to its canonical MIME content type.
/// Correct MIME types are required so browsers open PDFs inline and
/// Word documents prompt a compatible application rather than a raw download.
/// </summary>
/// <param name="extension">Lowercase file extension including the leading dot.</param>
/// <returns>MIME type string, or <c>application/octet-stream</c> as a safe fallback.</returns>
private static string GetContentType(string extension) => extension switch
{
".pdf" => "application/pdf",
".doc" => "application/msword",
".docx" => "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
".txt" => "text/plain",
_ => "application/octet-stream"
};
} }
@@ -0,0 +1,405 @@
using PowderCoating.Application.DTOs.Quote;
using PowderCoating.Application.Interfaces;
using PowderCoating.Core.Entities;
namespace PowderCoating.Application.Services;
public class JobItemAssemblyService : IJobItemAssemblyService
{
public JobItem CreateJobItem(CreateQuoteItemDto source, int jobId, int companyId, QuoteItemPricingResult pricing, DateTime createdAtUtc)
{
ArgumentNullException.ThrowIfNull(source);
ArgumentNullException.ThrowIfNull(pricing);
return BuildJobItem(
new JobItemSeed
{
Description = source.Description,
Quantity = source.Quantity,
SurfaceAreaSqFt = source.SurfaceAreaSqFt,
CatalogItemId = source.CatalogItemId,
IsGenericItem = source.IsGenericItem,
IsLaborItem = source.IsLaborItem,
IsSalesItem = source.IsSalesItem,
Sku = source.Sku,
ManualUnitPrice = source.ManualUnitPrice,
PowderCostOverride = source.PowderCostOverride,
UnitPrice = pricing.UnitPrice,
TotalPrice = pricing.TotalPrice,
LaborCost = pricing.TotalPrice * 0.4m,
RequiresSandblasting = source.RequiresSandblasting,
RequiresMasking = source.RequiresMasking,
EstimatedMinutes = source.EstimatedMinutes,
Notes = source.Notes,
IncludePrepCost = source.IncludePrepCost,
Complexity = source.Complexity,
AiTags = source.AiTags,
AiPredictionId = source.AiPredictionId
},
jobId,
companyId,
createdAtUtc);
}
public IReadOnlyList<JobItemCoat> CreateJobItemCoats(CreateQuoteItemDto source, int jobItemId, int companyId, DateTime createdAtUtc)
{
ArgumentNullException.ThrowIfNull(source);
return source.Coats?
.OrderBy(c => c.Sequence)
.Select(c => BuildJobItemCoat(
new JobItemCoatSeed
{
CoatName = c.CoatName,
Sequence = c.Sequence,
InventoryItemId = c.InventoryItemId,
ColorName = c.ColorName,
VendorId = c.VendorId,
ColorCode = c.ColorCode,
Finish = c.Finish,
CoverageSqFtPerLb = c.CoverageSqFtPerLb,
TransferEfficiency = c.TransferEfficiency,
PowderCostPerLb = c.PowderCostPerLb,
PowderToOrder = CalculatePowderToOrder(c.PowderToOrder, source.SurfaceAreaSqFt, source.Quantity, c.CoverageSqFtPerLb, c.TransferEfficiency),
Notes = c.Notes
},
jobItemId,
companyId,
createdAtUtc))
.ToList() ?? [];
}
public IReadOnlyList<JobItemPrepService> CreateJobItemPrepServices(CreateQuoteItemDto source, int jobItemId, int companyId, DateTime createdAtUtc)
{
ArgumentNullException.ThrowIfNull(source);
return BuildJobItemPrepServices(
source.PrepServices?.Select(p => new JobItemPrepServiceSeed
{
PrepServiceId = p.PrepServiceId,
EstimatedMinutes = p.EstimatedMinutes,
BlastSetupId = p.BlastSetupId
}),
jobItemId,
companyId,
createdAtUtc);
}
public JobItem CreateJobItem(QuoteItem source, int jobId, int companyId, DateTime createdAtUtc)
{
ArgumentNullException.ThrowIfNull(source);
var firstCoat = source.Coats?
.OrderBy(c => c.Sequence)
.FirstOrDefault();
return BuildJobItem(
new JobItemSeed
{
Description = source.Description,
Quantity = source.Quantity,
ColorName = firstCoat?.ColorName,
ColorCode = firstCoat?.ColorCode,
Finish = firstCoat?.Finish,
SurfaceArea = source.SurfaceAreaSqFt,
SurfaceAreaSqFt = source.SurfaceAreaSqFt,
CatalogItemId = source.CatalogItemId,
IsGenericItem = source.IsGenericItem,
IsLaborItem = source.IsLaborItem,
IsSalesItem = source.IsSalesItem,
Sku = source.Sku,
ManualUnitPrice = source.ManualUnitPrice,
PowderCostOverride = source.PowderCostOverride,
UnitPrice = source.UnitPrice,
TotalPrice = source.TotalPrice,
LaborCost = source.TotalPrice * 0.4m,
RequiresSandblasting = source.RequiresSandblasting,
RequiresMasking = source.RequiresMasking,
EstimatedMinutes = source.EstimatedMinutes,
Notes = source.Notes,
IncludePrepCost = source.IncludePrepCost,
Complexity = source.Complexity,
AiTags = source.AiTags,
AiPredictionId = source.AiPredictionId
},
jobId,
companyId,
createdAtUtc);
}
public IReadOnlyList<JobItemCoat> CreateJobItemCoats(QuoteItem source, int jobItemId, int companyId, DateTime createdAtUtc)
{
ArgumentNullException.ThrowIfNull(source);
return source.Coats?
.OrderBy(c => c.Sequence)
.Select(c =>
{
var appearance = ResolveCoatAppearance(c.ColorName, c.ColorCode, c.Finish, c.InventoryItem);
return BuildJobItemCoat(
new JobItemCoatSeed
{
CoatName = c.CoatName,
Sequence = c.Sequence,
InventoryItemId = c.InventoryItemId,
ColorName = appearance.ColorName,
VendorId = c.VendorId,
ColorCode = appearance.ColorCode,
Finish = appearance.Finish,
CoverageSqFtPerLb = c.CoverageSqFtPerLb,
TransferEfficiency = c.TransferEfficiency,
PowderCostPerLb = c.PowderCostPerLb,
PowderToOrder = CalculatePowderToOrder(c.PowderToOrder, source.SurfaceAreaSqFt, source.Quantity, c.CoverageSqFtPerLb, c.TransferEfficiency),
Notes = c.Notes
},
jobItemId,
companyId,
createdAtUtc);
})
.ToList() ?? [];
}
public IReadOnlyList<JobItemPrepService> CreateJobItemPrepServices(QuoteItem source, int jobItemId, int companyId, DateTime createdAtUtc)
{
ArgumentNullException.ThrowIfNull(source);
return BuildJobItemPrepServices(
source.PrepServices?.Select(p => new JobItemPrepServiceSeed
{
PrepServiceId = p.PrepServiceId,
EstimatedMinutes = p.EstimatedMinutes,
BlastSetupId = p.BlastSetupId
}),
jobItemId,
companyId,
createdAtUtc);
}
public JobItem CreateJobItem(JobItem source, int jobId, int companyId, DateTime createdAtUtc)
{
ArgumentNullException.ThrowIfNull(source);
return BuildJobItem(
new JobItemSeed
{
Description = source.Description,
Quantity = source.Quantity,
ColorName = source.ColorName,
ColorCode = source.ColorCode,
Finish = source.Finish,
SurfaceArea = source.SurfaceArea,
SurfaceAreaSqFt = source.SurfaceAreaSqFt,
CatalogItemId = source.CatalogItemId,
IsGenericItem = source.IsGenericItem,
IsLaborItem = source.IsLaborItem,
IsSalesItem = source.IsSalesItem,
Sku = source.Sku,
ManualUnitPrice = source.ManualUnitPrice,
PowderCostOverride = source.PowderCostOverride,
UnitPrice = source.UnitPrice,
TotalPrice = source.TotalPrice,
LaborCost = source.LaborCost,
RequiresSandblasting = source.RequiresSandblasting,
RequiresMasking = source.RequiresMasking,
EstimatedMinutes = source.EstimatedMinutes,
Notes = source.Notes,
IncludePrepCost = source.IncludePrepCost,
Complexity = source.Complexity,
AiTags = source.AiTags,
AiPredictionId = source.AiPredictionId
},
jobId,
companyId,
createdAtUtc);
}
public IReadOnlyList<JobItemCoat> CreateJobItemCoats(JobItem source, int jobItemId, int companyId, DateTime createdAtUtc)
{
ArgumentNullException.ThrowIfNull(source);
return source.Coats?
.OrderBy(c => c.Sequence)
.Select(c => BuildJobItemCoat(
new JobItemCoatSeed
{
CoatName = c.CoatName,
Sequence = c.Sequence,
InventoryItemId = c.InventoryItemId,
ColorName = c.ColorName,
VendorId = c.VendorId,
ColorCode = c.ColorCode,
Finish = c.Finish,
CoverageSqFtPerLb = c.CoverageSqFtPerLb,
TransferEfficiency = c.TransferEfficiency,
PowderCostPerLb = c.PowderCostPerLb,
PowderToOrder = c.PowderToOrder,
Notes = c.Notes
},
jobItemId,
companyId,
createdAtUtc))
.ToList() ?? [];
}
public IReadOnlyList<JobItemPrepService> CreateJobItemPrepServices(JobItem source, int jobItemId, int companyId, DateTime createdAtUtc)
{
ArgumentNullException.ThrowIfNull(source);
return BuildJobItemPrepServices(
source.PrepServices?.Select(p => new JobItemPrepServiceSeed
{
PrepServiceId = p.PrepServiceId,
EstimatedMinutes = p.EstimatedMinutes,
BlastSetupId = p.BlastSetupId
}),
jobItemId,
companyId,
createdAtUtc);
}
private static JobItem BuildJobItem(JobItemSeed seed, int jobId, int companyId, DateTime createdAtUtc)
{
return new JobItem
{
JobId = jobId,
Description = seed.Description,
Quantity = seed.Quantity,
ColorName = seed.ColorName,
ColorCode = seed.ColorCode,
Finish = seed.Finish,
SurfaceArea = seed.SurfaceArea,
SurfaceAreaSqFt = seed.SurfaceAreaSqFt,
CatalogItemId = seed.CatalogItemId,
IsGenericItem = seed.IsGenericItem,
IsLaborItem = seed.IsLaborItem,
IsSalesItem = seed.IsSalesItem,
Sku = seed.Sku,
ManualUnitPrice = seed.ManualUnitPrice,
PowderCostOverride = seed.PowderCostOverride,
UnitPrice = seed.UnitPrice,
TotalPrice = seed.TotalPrice,
LaborCost = seed.LaborCost,
RequiresSandblasting = seed.RequiresSandblasting,
RequiresMasking = seed.RequiresMasking,
EstimatedMinutes = seed.EstimatedMinutes,
Notes = seed.Notes,
IncludePrepCost = seed.IncludePrepCost,
Complexity = seed.Complexity,
AiTags = seed.AiTags,
AiPredictionId = seed.AiPredictionId,
CompanyId = companyId,
CreatedAt = createdAtUtc
};
}
private static JobItemCoat BuildJobItemCoat(JobItemCoatSeed seed, int jobItemId, int companyId, DateTime createdAtUtc)
{
return new JobItemCoat
{
JobItemId = jobItemId,
CoatName = seed.CoatName,
Sequence = seed.Sequence,
InventoryItemId = seed.InventoryItemId,
ColorName = seed.ColorName,
VendorId = seed.VendorId,
ColorCode = seed.ColorCode,
Finish = seed.Finish,
CoverageSqFtPerLb = seed.CoverageSqFtPerLb,
TransferEfficiency = seed.TransferEfficiency,
PowderCostPerLb = seed.PowderCostPerLb,
PowderToOrder = seed.PowderToOrder,
Notes = seed.Notes,
CompanyId = companyId,
CreatedAt = createdAtUtc
};
}
private static IReadOnlyList<JobItemPrepService> BuildJobItemPrepServices(IEnumerable<JobItemPrepServiceSeed>? seeds, int jobItemId, int companyId, DateTime createdAtUtc)
{
return seeds?
.Select(seed => new JobItemPrepService
{
JobItemId = jobItemId,
PrepServiceId = seed.PrepServiceId,
EstimatedMinutes = seed.EstimatedMinutes,
BlastSetupId = seed.BlastSetupId,
CompanyId = companyId,
CreatedAt = createdAtUtc
})
.ToList() ?? [];
}
private static decimal? CalculatePowderToOrder(decimal? storedPowderToOrder, decimal surfaceAreaSqFt, decimal quantity, decimal coverageSqFtPerLb, decimal transferEfficiency)
{
if (storedPowderToOrder.HasValue && storedPowderToOrder.Value > 0)
return storedPowderToOrder;
if (surfaceAreaSqFt <= 0)
return null;
var coverage = coverageSqFtPerLb > 0 ? coverageSqFtPerLb : 30m;
var efficiency = transferEfficiency > 0 ? transferEfficiency / 100m : 0.65m;
return Math.Round((surfaceAreaSqFt * quantity) / (coverage * efficiency), 2);
}
private static (string? ColorName, string? ColorCode, string? Finish) ResolveCoatAppearance(
string? colorName,
string? colorCode,
string? finish,
InventoryItem? inventoryItem)
{
if (inventoryItem == null)
return (colorName, colorCode, finish);
return (inventoryItem.Name, inventoryItem.ColorCode, inventoryItem.Finish);
}
private sealed class JobItemSeed
{
public string Description { get; init; } = string.Empty;
public decimal Quantity { get; init; }
public string? ColorName { get; init; }
public string? ColorCode { get; init; }
public string? Finish { get; init; }
public decimal? SurfaceArea { get; init; }
public decimal SurfaceAreaSqFt { get; init; }
public int? CatalogItemId { get; init; }
public bool IsGenericItem { get; init; }
public bool IsLaborItem { get; init; }
public bool IsSalesItem { get; init; }
public string? Sku { get; init; }
public decimal? ManualUnitPrice { get; init; }
public decimal? PowderCostOverride { get; init; }
public decimal UnitPrice { get; init; }
public decimal TotalPrice { get; init; }
public decimal LaborCost { get; init; }
public bool RequiresSandblasting { get; init; }
public bool RequiresMasking { get; init; }
public int EstimatedMinutes { get; init; }
public string? Notes { get; init; }
public bool IncludePrepCost { get; init; }
public string? Complexity { get; init; }
public string? AiTags { get; init; }
public int? AiPredictionId { get; init; }
}
private sealed class JobItemCoatSeed
{
public string CoatName { get; init; } = string.Empty;
public int Sequence { get; init; }
public int? InventoryItemId { get; init; }
public string? ColorName { get; init; }
public int? VendorId { get; init; }
public string? ColorCode { get; init; }
public string? Finish { get; init; }
public decimal CoverageSqFtPerLb { get; init; }
public decimal TransferEfficiency { get; init; }
public decimal? PowderCostPerLb { get; init; }
public decimal? PowderToOrder { get; init; }
public string? Notes { get; init; }
}
private sealed class JobItemPrepServiceSeed
{
public int PrepServiceId { get; init; }
public int EstimatedMinutes { get; init; }
public int? BlastSetupId { get; init; }
}
}
@@ -69,19 +69,13 @@ public class JobPhotoService : IJobPhotoService
string? caption = null, string? caption = null,
JobPhotoType photoType = JobPhotoType.Progress) JobPhotoType photoType = JobPhotoType.Progress)
{ {
if (file == null || file.Length == 0) var (isValid, extension, error) = BlobFileHelper.ValidateUpload(file, AllowedImageTypes, MaxPhotoSize);
return (false, string.Empty, "No file was uploaded."); if (!isValid)
return (false, string.Empty, error);
if (file.Length > MaxPhotoSize)
return (false, string.Empty, "Photo must be smaller than 10 MB.");
var extension = Path.GetExtension(file.FileName).ToLowerInvariant();
if (string.IsNullOrEmpty(extension) || !AllowedImageTypes.Contains(extension))
return (false, string.Empty, "Only JPG, PNG, GIF, and WebP images are allowed.");
// SECURITY: Use GUID for blob name to prevent enumeration // SECURITY: Use GUID for blob name to prevent enumeration
var blobName = $"{companyId}/job-photos/{jobId}/{Guid.NewGuid()}{extension}"; var blobName = $"{companyId}/job-photos/{jobId}/{Guid.NewGuid()}{extension}";
var contentType = GetContentType(extension); var contentType = BlobFileHelper.GetContentType(extension);
using var stream = file.OpenReadStream(); using var stream = file.OpenReadStream();
var result = await _blobService.UploadAsync(_settings.Containers.JobImages, blobName, stream, contentType); var result = await _blobService.UploadAsync(_settings.Containers.JobImages, blobName, stream, contentType);
@@ -137,19 +131,4 @@ public class JobPhotoService : IJobPhotoService
return await _blobService.ExistsAsync(_settings.Containers.JobImages, filePath); return await _blobService.ExistsAsync(_settings.Containers.JobImages, filePath);
} }
/// <summary>
/// Maps a lowercase file extension to its canonical MIME content type.
/// Falls back to <c>image/jpeg</c> (rather than octet-stream) because all
/// allowed extensions are image types and browsers will render them correctly.
/// </summary>
/// <param name="extension">Lowercase file extension including the leading dot.</param>
/// <returns>MIME type string.</returns>
private static string GetContentType(string extension) => extension switch
{
".jpg" or ".jpeg" => "image/jpeg",
".png" => "image/png",
".gif" => "image/gif",
".webp" => "image/webp",
_ => "image/jpeg"
};
} }
@@ -2357,4 +2357,356 @@ public class PdfService : IPdfService
return document.GeneratePdf(); return document.GeneratePdf();
}); });
} }
/// <summary>
/// Generates an Accounts Payable Aging PDF. Layout mirrors GenerateArAgingPdfAsync:
/// a KPI summary band, a per-vendor summary table with aging columns, then a bill-detail
/// section grouped by vendor. Uses a red accent palette to visually distinguish AP from AR.
/// </summary>
public async Task<byte[]> GenerateApAgingPdfAsync(ApAgingReportDto dto)
{
QuestPDF.Settings.License = LicenseType.Community;
const string accent = "#b91c1c";
return await Task.Run(() =>
{
var document = Document.Create(container =>
{
container.Page(page =>
{
page.Size(PageSizes.Letter);
page.Margin(0.6f, Unit.Inch);
page.PageColor(Colors.White);
page.DefaultTextStyle(x => x.FontSize(9).FontFamily("Arial"));
page.Header().Element(c => ComposeReportHeader(c, dto.CompanyName, "Accounts Payable Aging",
$"As of {dto.AsOf:MMMM d, yyyy}", accent));
page.Content().PaddingTop(12).Column(col =>
{
col.Item().Background("#f8fafc").Border(1).BorderColor("#e2e8f0").Padding(8).Row(row =>
{
KpiCell(row, "Current", dto.TotalCurrent.ToString("C0"), "#16a34a");
KpiCell(row, "130 Days", dto.Total1to30.ToString("C0"), "#ca8a04");
KpiCell(row, "3160 Days", dto.Total31to60.ToString("C0"), "#ea580c");
KpiCell(row, "6190 Days", dto.Total61to90.ToString("C0"), "#dc2626");
KpiCell(row, "Over 90", dto.TotalOver90.ToString("C0"), "#7f1d1d");
KpiCell(row, "Total Owed", dto.TotalOutstanding.ToString("C0"), accent);
});
if (!dto.Vendors.Any())
{
col.Item().PaddingTop(20).AlignCenter()
.Text("All bills are paid — no outstanding balances.")
.FontSize(11).FontColor("#16a34a");
return;
}
col.Item().PaddingTop(14).Table(table =>
{
table.ColumnsDefinition(cols =>
{
cols.RelativeColumn(3);
cols.RelativeColumn(2);
cols.RelativeColumn(2);
cols.RelativeColumn(2);
cols.RelativeColumn(2);
cols.RelativeColumn(2);
cols.RelativeColumn(2);
});
table.Header(h =>
{
foreach (var lbl in new[] { "Vendor", "Current", "130", "3160", "6190", "Over 90", "Total" })
h.Cell().Background(accent).Padding(4).Text(lbl).FontColor(Colors.White).Bold().FontSize(8);
});
var alt = false;
foreach (var vend in dto.Vendors)
{
var bg = alt ? "#f8fafc" : "#ffffff";
table.Cell().Background(bg).Padding(4).Text(vend.VendorName).FontSize(9).Bold();
table.Cell().Background(bg).AlignRight().Padding(4).Text(vend.TotalCurrent > 0 ? vend.TotalCurrent.ToString("C") : "—").FontSize(9).FontColor("#16a34a");
table.Cell().Background(bg).AlignRight().Padding(4).Text(vend.Total1to30 > 0 ? vend.Total1to30.ToString("C") : "—").FontSize(9).FontColor("#ca8a04");
table.Cell().Background(bg).AlignRight().Padding(4).Text(vend.Total31to60 > 0 ? vend.Total31to60.ToString("C") : "—").FontSize(9).FontColor("#ea580c");
table.Cell().Background(bg).AlignRight().Padding(4).Text(vend.Total61to90 > 0 ? vend.Total61to90.ToString("C") : "—").FontSize(9).FontColor("#dc2626");
table.Cell().Background(bg).AlignRight().Padding(4).Text(vend.TotalOver90 > 0 ? vend.TotalOver90.ToString("C") : "—").FontSize(9).FontColor("#7f1d1d");
table.Cell().Background(bg).AlignRight().Padding(4).Text(vend.TotalBalance.ToString("C")).FontSize(9).Bold();
alt = !alt;
}
table.Cell().Background("#e2e8f0").Padding(4).Text("Total").FontSize(9).Bold();
table.Cell().Background("#e2e8f0").AlignRight().Padding(4).Text(dto.TotalCurrent.ToString("C")).FontSize(9).Bold().FontColor("#16a34a");
table.Cell().Background("#e2e8f0").AlignRight().Padding(4).Text(dto.Total1to30.ToString("C")).FontSize(9).Bold().FontColor("#ca8a04");
table.Cell().Background("#e2e8f0").AlignRight().Padding(4).Text(dto.Total31to60.ToString("C")).FontSize(9).Bold().FontColor("#ea580c");
table.Cell().Background("#e2e8f0").AlignRight().Padding(4).Text(dto.Total61to90.ToString("C")).FontSize(9).Bold().FontColor("#dc2626");
table.Cell().Background("#e2e8f0").AlignRight().Padding(4).Text(dto.TotalOver90.ToString("C")).FontSize(9).Bold().FontColor("#7f1d1d");
table.Cell().Background("#e2e8f0").AlignRight().Padding(4).Text(dto.TotalOutstanding.ToString("C")).FontSize(9).Bold();
});
col.Item().PaddingTop(16).Text("Bill Detail").FontSize(11).Bold();
foreach (var vend in dto.Vendors)
{
col.Item().PaddingTop(8).ShowEntire().Column(vendCol =>
{
vendCol.Item().Background("#f1f5f9").Padding(4).Text(vend.VendorName).Bold().FontSize(10);
vendCol.Item().Table(table =>
{
table.ColumnsDefinition(cols =>
{
cols.RelativeColumn(2);
cols.RelativeColumn(2);
cols.RelativeColumn(2);
cols.RelativeColumn(2);
cols.RelativeColumn(2);
});
table.Header(h =>
{
foreach (var lbl in new[] { "Bill #", "Bill Date", "Due Date", "Balance", "Age" })
h.Cell().Background("#e2e8f0").Padding(3).Text(lbl).Bold().FontSize(8);
});
foreach (var bill in vend.Bills.OrderBy(b => b.DaysOverdue))
{
var ageColor = bill.DaysOverdue <= 0 ? "#16a34a"
: bill.DaysOverdue <= 30 ? "#ca8a04"
: bill.DaysOverdue <= 60 ? "#ea580c"
: bill.DaysOverdue <= 90 ? "#dc2626"
: "#7f1d1d";
var ageLabel = bill.DaysOverdue <= 0 ? "Current" : $"{bill.DaysOverdue}d overdue";
table.Cell().Padding(3).Text(bill.BillNumber).FontSize(8);
table.Cell().Padding(3).Text(bill.BillDate.ToString("MM/dd/yyyy")).FontSize(8).FontColor(Colors.Grey.Darken1);
table.Cell().Padding(3).Text(bill.DueDate?.ToString("MM/dd/yyyy") ?? "—").FontSize(8).FontColor(Colors.Grey.Darken1);
table.Cell().AlignRight().Padding(3).Text(bill.BalanceDue.ToString("C")).Bold().FontSize(8)
.FontColor(bill.DaysOverdue > 30 ? "#dc2626" : "#000000");
table.Cell().Padding(3).Text(ageLabel).FontSize(8).FontColor(ageColor);
}
table.Cell().ColumnSpan(3).Background("#f1f5f9").AlignRight().Padding(3)
.Text($"{vend.VendorName} subtotal").Bold().FontSize(8).FontColor(Colors.Grey.Darken2);
table.Cell().Background("#f1f5f9").AlignRight().Padding(3).Text(vend.TotalBalance.ToString("C")).Bold().FontSize(8);
table.Cell().Background("#f1f5f9");
});
});
}
});
page.Footer().AlignCenter().Text(text =>
{
text.CurrentPageNumber(); text.Span(" / "); text.TotalPages();
text.Span($" · {dto.CompanyName} · Generated {DateTime.Now:MMM d, yyyy}").FontSize(8).FontColor(Colors.Grey.Darken1);
});
});
});
return document.GeneratePdf();
});
}
/// <summary>
/// Generates a Trial Balance PDF. Each active account appears once with its balance in either
/// the Debit or Credit column based on AccountingRules sign conventions. A footer row shows
/// totals and a balanced/unbalanced indicator.
/// </summary>
public async Task<byte[]> GenerateTrialBalancePdfAsync(TrialBalanceDto dto)
{
QuestPDF.Settings.License = LicenseType.Community;
const string accent = "#1a56db";
return await Task.Run(() =>
{
var document = Document.Create(container =>
{
container.Page(page =>
{
page.Size(PageSizes.Letter);
page.Margin(0.6f, Unit.Inch);
page.PageColor(Colors.White);
page.DefaultTextStyle(x => x.FontSize(9).FontFamily("Arial"));
page.Header().Element(c => ComposeReportHeader(c, dto.CompanyName, "Trial Balance",
$"As of {dto.AsOf:MMMM d, yyyy}", accent));
page.Content().PaddingTop(12).Column(col =>
{
col.Item().Background("#f8fafc").Border(1).BorderColor("#e2e8f0").Padding(8).Row(row =>
{
KpiCell(row, "Total Debits", dto.TotalDebits.ToString("C0"), "#1a56db");
KpiCell(row, "Total Credits", dto.TotalCredits.ToString("C0"), "#1a56db");
KpiCell(row, "Status", dto.IsBalanced ? "Balanced ✓" : "Out of Balance ✗",
dto.IsBalanced ? "#16a34a" : "#dc2626");
});
if (!dto.Lines.Any())
{
col.Item().PaddingTop(20).AlignCenter()
.Text("No active accounts with balances found.")
.FontSize(11).FontColor(Colors.Grey.Darken1);
return;
}
col.Item().PaddingTop(14).Table(table =>
{
table.ColumnsDefinition(cols =>
{
cols.ConstantColumn(70);
cols.RelativeColumn(4);
cols.RelativeColumn(2);
cols.RelativeColumn(2);
cols.RelativeColumn(2);
});
table.Header(h =>
{
foreach (var lbl in new[] { "Acct #", "Account Name", "Type", "Debit", "Credit" })
h.Cell().Background(accent).Padding(4).Text(lbl).FontColor(Colors.White).Bold().FontSize(8);
});
var alt = false;
foreach (var line in dto.Lines)
{
var bg = alt ? "#f8fafc" : "#ffffff";
table.Cell().Background(bg).Padding(4).Text(line.AccountNumber).FontSize(8).FontColor(Colors.Grey.Darken2);
table.Cell().Background(bg).Padding(4).Text(line.AccountName).FontSize(9);
table.Cell().Background(bg).Padding(4).Text(line.AccountType.ToString()).FontSize(8).FontColor(Colors.Grey.Darken1);
table.Cell().Background(bg).AlignRight().Padding(4).Text(line.DebitBalance > 0 ? line.DebitBalance.ToString("C") : "").FontSize(9);
table.Cell().Background(bg).AlignRight().Padding(4).Text(line.CreditBalance > 0 ? line.CreditBalance.ToString("C") : "").FontSize(9);
alt = !alt;
}
table.Cell().ColumnSpan(3).Background("#e2e8f0").Padding(4).Text("Total").FontSize(9).Bold();
table.Cell().Background("#e2e8f0").AlignRight().Padding(4).Text(dto.TotalDebits.ToString("C")).FontSize(9).Bold();
table.Cell().Background("#e2e8f0").AlignRight().Padding(4).Text(dto.TotalCredits.ToString("C")).FontSize(9).Bold();
});
});
page.Footer().AlignCenter().Text(text =>
{
text.CurrentPageNumber(); text.Span(" / "); text.TotalPages();
text.Span($" · {dto.CompanyName} · Generated {DateTime.Now:MMM d, yyyy}").FontSize(8).FontColor(Colors.Grey.Darken1);
});
});
});
return document.GeneratePdf();
});
}
/// <summary>
/// Generates a Cash Flow Statement PDF with three sections (Operating, Investing, Financing)
/// plus a summary reconciling beginning → ending cash. Uses a teal accent palette to
/// visually distinguish it from the other financial statements.
/// </summary>
public async Task<byte[]> GenerateCashFlowStatementPdfAsync(CashFlowStatementDto dto)
{
QuestPDF.Settings.License = LicenseType.Community;
const string accent = "#0891b2";
return await Task.Run(() =>
{
var document = Document.Create(container =>
{
container.Page(page =>
{
page.Size(PageSizes.Letter);
page.Margin(0.6f, Unit.Inch);
page.PageColor(Colors.White);
page.DefaultTextStyle(x => x.FontSize(9).FontFamily("Arial"));
page.Header().Element(c => ComposeReportHeader(c, dto.CompanyName, "Cash Flow Statement",
$"{dto.From:MMMM d, yyyy} {dto.To:MMMM d, yyyy}", accent));
page.Content().PaddingTop(12).Column(col =>
{
col.Spacing(4);
// ── Operating Activities ──────────────────────────────────────
col.Item().Text("Operating Activities").Bold().FontSize(11).FontColor(accent);
col.Item().Table(t =>
{
t.ColumnsDefinition(c => { c.RelativeColumn(3); c.ConstantColumn(100); });
CfRow(t, "Cash Received from Customers", dto.CashFromCustomers, false);
CfRow(t, "Cash Paid to Vendors (Bills)", -dto.CashToVendors, false);
CfRow(t, "Cash Paid for Expenses", -dto.CashForExpenses, false);
CfTotalRow(t, "Net Cash from Operating Activities", dto.NetOperating);
});
col.Item().PaddingTop(10).Text("Investing Activities").Bold().FontSize(11).FontColor(accent);
col.Item().Table(t =>
{
t.ColumnsDefinition(c => { c.RelativeColumn(3); c.ConstantColumn(100); });
if (dto.InvestingLines.Count == 0)
CfRow(t, "No investing activities recorded", 0, true);
else
foreach (var line in dto.InvestingLines)
CfRow(t, line.Label, line.Amount, false);
CfTotalRow(t, "Net Cash from Investing Activities", dto.NetInvesting);
});
col.Item().PaddingTop(10).Text("Financing Activities").Bold().FontSize(11).FontColor(accent);
col.Item().Table(t =>
{
t.ColumnsDefinition(c => { c.RelativeColumn(3); c.ConstantColumn(100); });
if (dto.FinancingLines.Count == 0)
CfRow(t, "No financing activities recorded", 0, true);
else
foreach (var line in dto.FinancingLines)
CfRow(t, line.Label, line.Amount, false);
CfTotalRow(t, "Net Cash from Financing Activities", dto.NetFinancing);
});
// ── Summary ───────────────────────────────────────────────────
col.Item().PaddingTop(12).Table(t =>
{
t.ColumnsDefinition(c => { c.RelativeColumn(3); c.ConstantColumn(100); });
void SumRow(string label, decimal amount, bool bold = false)
{
var bg = bold ? "#e0f2fe" : "#ffffff";
var lText = t.Cell().Background(bg).PaddingVertical(4).PaddingHorizontal(6).Text(label).FontSize(9);
if (bold) lText.Bold();
var vText = t.Cell().Background(bg).PaddingVertical(4).PaddingHorizontal(6).AlignRight()
.Text(amount.ToString("C")).FontSize(9)
.FontColor(amount < 0 ? Colors.Red.Darken2 : Colors.Black);
if (bold) vText.Bold();
}
SumRow("Beginning Cash Balance", dto.BeginningCash);
SumRow("Net Change in Cash", dto.NetChangeInCash);
SumRow("Ending Cash Balance", dto.EndingCash, bold: true);
});
});
page.Footer().AlignCenter().Text(text =>
{
text.CurrentPageNumber(); text.Span(" / "); text.TotalPages();
text.Span($" · {dto.CompanyName} · Generated {DateTime.Now:MMM d, yyyy}").FontSize(8).FontColor(Colors.Grey.Darken1);
});
});
});
return document.GeneratePdf();
});
static void CfRow(TableDescriptor t, string label, decimal amount, bool muted)
{
t.Cell().BorderBottom(0.5f).BorderColor("#e5e7eb")
.PaddingVertical(3).PaddingHorizontal(6)
.Text(label).FontSize(9).FontColor(muted ? Colors.Grey.Medium : Colors.Black);
t.Cell().BorderBottom(0.5f).BorderColor("#e5e7eb")
.PaddingVertical(3).PaddingHorizontal(6).AlignRight()
.Text(muted ? "" : amount.ToString("C")).FontSize(9)
.FontColor(amount < 0 ? Colors.Red.Darken2 : Colors.Black);
}
static void CfTotalRow(TableDescriptor t, string label, decimal amount)
{
t.Cell().Background("#f0f9ff").PaddingVertical(4).PaddingHorizontal(6)
.Text(label).Bold().FontSize(9);
t.Cell().Background("#f0f9ff").PaddingVertical(4).PaddingHorizontal(6).AlignRight()
.Text(amount.ToString("C")).Bold().FontSize(9)
.FontColor(amount < 0 ? Colors.Red.Darken2 : Colors.Black);
}
}
} }
@@ -590,53 +590,9 @@ public class PricingCalculationService : IPricingCalculationService
{ {
QuoteItemPricingResult itemResult; QuoteItemPricingResult itemResult;
// Catalog items - if they have coats, add coat costs to catalog base price // All items (catalog and calculated) go through CalculateQuoteItemPriceAsync, which
if (item.CatalogItemId.HasValue) // handles PowderCostOverride, prep cost inclusion, and all item type variants.
{
var catalogItem = await _unitOfWork.CatalogItems.GetByIdAsync(item.CatalogItemId.Value);
if (catalogItem != null)
{
// If the catalog item has coats, calculate using CalculateQuoteItemPriceAsync
// (which already includes the catalog base price + coat costs)
if (item.Coats != null && item.Coats.Any())
{
// CalculateQuoteItemPriceAsync already adds catalog base price to coat costs
itemResult = await CalculateQuoteItemPriceAsync(item, companyId, ovenCostOverride); 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); itemResults.Add(itemResult);
} }
@@ -66,22 +66,16 @@ public class ProfilePhotoService : IProfilePhotoService
string userId, string userId,
int companyId) int companyId)
{ {
if (file == null || file.Length == 0) var (isValid, extension, error) = BlobFileHelper.ValidateUpload(file, AllowedImageTypes, MaxPhotoSize);
return (false, string.Empty, "No file was uploaded."); if (!isValid)
return (false, string.Empty, error);
if (file.Length > MaxPhotoSize)
return (false, string.Empty, "Photo must be smaller than 10 MB.");
var extension = Path.GetExtension(file.FileName).ToLowerInvariant();
if (string.IsNullOrEmpty(extension) || !AllowedImageTypes.Contains(extension))
return (false, string.Empty, "Only JPG, PNG, GIF, and WebP images are allowed.");
// Delete old photos for this user with different extensions // Delete old photos for this user with different extensions
await DeleteOldPhotosForUserAsync(companyId, userId, extension); await DeleteOldPhotosForUserAsync(companyId, userId, extension);
// Blob path mirrors former filesystem path // Blob path mirrors former filesystem path
var blobName = $"{companyId}/profile-photos/{userId}{extension}"; var blobName = $"{companyId}/profile-photos/{userId}{extension}";
var contentType = GetContentType(extension); var contentType = BlobFileHelper.GetContentType(extension);
using var stream = file.OpenReadStream(); using var stream = file.OpenReadStream();
var result = await _blobService.UploadAsync(_settings.Containers.ProfileImages, blobName, stream, contentType); var result = await _blobService.UploadAsync(_settings.Containers.ProfileImages, blobName, stream, contentType);
@@ -172,19 +166,4 @@ public class ProfilePhotoService : IProfilePhotoService
} }
} }
/// <summary>
/// Maps a lowercase file extension to its canonical MIME content type.
/// Falls back to <c>image/jpeg</c> (rather than octet-stream) because all
/// allowed extensions are image types and browsers will render them correctly.
/// </summary>
/// <param name="extension">Lowercase file extension including the leading dot.</param>
/// <returns>MIME type string.</returns>
private static string GetContentType(string extension) => extension switch
{
".jpg" or ".jpeg" => "image/jpeg",
".png" => "image/png",
".gif" => "image/gif",
".webp" => "image/webp",
_ => "image/jpeg"
};
} }
@@ -50,19 +50,13 @@ public class QuotePhotoService : IQuotePhotoService
public async Task<(bool Success, string TempId, string FilePath, string ErrorMessage)> SaveTempPhotoAsync( public async Task<(bool Success, string TempId, string FilePath, string ErrorMessage)> SaveTempPhotoAsync(
IFormFile file, int companyId) IFormFile file, int companyId)
{ {
if (file == null || file.Length == 0) var (isValid, ext, validationError) = BlobFileHelper.ValidateUpload(file, AllowedExtensions, MaxFileSizeBytes);
return (false, string.Empty, string.Empty, "No file provided."); if (!isValid)
return (false, string.Empty, string.Empty, validationError);
if (file.Length > MaxFileSizeBytes)
return (false, string.Empty, string.Empty, "File exceeds the 10 MB limit.");
var ext = Path.GetExtension(file.FileName).ToLowerInvariant();
if (!AllowedExtensions.Contains(ext))
return (false, string.Empty, string.Empty, $"File type '{ext}' is not allowed.");
var tempId = Guid.NewGuid().ToString("N"); var tempId = Guid.NewGuid().ToString("N");
var blobName = $"temp/{tempId}/{Guid.NewGuid():N}{ext}"; var blobName = $"temp/{tempId}/{Guid.NewGuid():N}{ext}";
var contentType = GetContentType(ext); var contentType = BlobFileHelper.GetContentType(ext);
using var stream = file.OpenReadStream(); using var stream = file.OpenReadStream();
var result = await _blobService.UploadAsync(_settings.Containers.QuoteImages, blobName, stream, contentType); var result = await _blobService.UploadAsync(_settings.Containers.QuoteImages, blobName, stream, contentType);
@@ -100,7 +94,7 @@ public class QuotePhotoService : IQuotePhotoService
return (false, string.Empty, "Failed to read temp photo."); return (false, string.Empty, "Failed to read temp photo.");
using var ms = new MemoryStream(download.Content); using var ms = new MemoryStream(download.Content);
var upload = await _blobService.UploadAsync(_settings.Containers.QuoteImages, destBlob, ms, GetContentType(ext)); var upload = await _blobService.UploadAsync(_settings.Containers.QuoteImages, destBlob, ms, BlobFileHelper.GetContentType(ext));
if (!upload.Success) if (!upload.Success)
return (false, string.Empty, "Failed to save permanent photo."); return (false, string.Empty, "Failed to save permanent photo.");
@@ -173,12 +167,4 @@ public class QuotePhotoService : IQuotePhotoService
} }
} }
private static string GetContentType(string ext) => ext switch
{
".jpg" or ".jpeg" => "image/jpeg",
".png" => "image/png",
".gif" => "image/gif",
".webp" => "image/webp",
_ => "image/jpeg"
};
} }
@@ -0,0 +1,372 @@
using PowderCoating.Application.DTOs.Quote;
using PowderCoating.Application.Interfaces;
using PowderCoating.Core.Entities;
using PowderCoating.Core.Interfaces;
using Microsoft.Extensions.Logging;
namespace PowderCoating.Application.Services;
public class QuotePricingAssemblyService : IQuotePricingAssemblyService
{
private readonly IUnitOfWork _unitOfWork;
private readonly IPricingCalculationService _pricingService;
private readonly IInventoryAiLookupService _aiLookupService;
private readonly ILogger<QuotePricingAssemblyService> _logger;
public QuotePricingAssemblyService(
IUnitOfWork unitOfWork,
IPricingCalculationService pricingService,
IInventoryAiLookupService aiLookupService,
ILogger<QuotePricingAssemblyService> logger)
{
_unitOfWork = unitOfWork;
_pricingService = pricingService;
_aiLookupService = aiLookupService;
_logger = logger;
}
public void ApplyPricingSnapshot(Quote quote, QuotePricingResult pricingResult)
{
ArgumentNullException.ThrowIfNull(quote);
ArgumentNullException.ThrowIfNull(pricingResult);
quote.MaterialCosts = pricingResult.MaterialCosts;
quote.LaborCosts = pricingResult.LaborCosts;
quote.EquipmentCosts = pricingResult.EquipmentCosts;
quote.ItemsSubtotal = pricingResult.ItemsSubtotal;
quote.OvenBatchCost = pricingResult.OvenBatchCost;
quote.ShopSuppliesAmount = pricingResult.ShopSuppliesAmount;
quote.ShopSuppliesPercent = pricingResult.ShopSuppliesPercent;
quote.OverheadAmount = pricingResult.OverheadCosts;
quote.OverheadPercent = pricingResult.OverheadPercent;
quote.ProfitMargin = pricingResult.ProfitMargin;
quote.ProfitPercent = pricingResult.ProfitPercent;
quote.SubTotal = pricingResult.SubtotalBeforeDiscount;
quote.DiscountPercent = pricingResult.DiscountPercent;
quote.DiscountAmount = pricingResult.DiscountAmount;
quote.RushFee = pricingResult.RushFee;
quote.TaxAmount = pricingResult.TaxAmount;
quote.Total = pricingResult.Total;
}
public async Task<IReadOnlyList<QuoteItem>> CreateQuoteItemsAsync(
IEnumerable<CreateQuoteItemDto> itemDtos,
int quoteId,
int companyId,
decimal? ovenRateOverride,
DateTime createdAtUtc)
{
ArgumentNullException.ThrowIfNull(itemDtos);
var items = new List<QuoteItem>();
foreach (var itemDto in itemDtos)
{
var item = BuildQuoteItem(itemDto, quoteId, companyId, createdAtUtc);
await ApplyPricingAsync(item, itemDto, companyId, ovenRateOverride);
await UpdateAiPredictionOverrideAsync(itemDto, item.UnitPrice);
item.Coats = await BuildQuoteItemCoatsAsync(itemDto, companyId, createdAtUtc);
item.PrepServices = BuildQuoteItemPrepServices(itemDto, companyId, createdAtUtc);
items.Add(item);
}
return items;
}
private async Task ApplyPricingAsync(QuoteItem item, CreateQuoteItemDto itemDto, int companyId, decimal? ovenRateOverride)
{
if (itemDto.IsAiItem && itemDto.ManualUnitPrice.HasValue && itemDto.ManualUnitPrice.Value > 0)
{
item.UnitPrice = itemDto.ManualUnitPrice.Value;
item.TotalPrice = itemDto.ManualUnitPrice.Value * itemDto.Quantity;
_logger.LogInformation("AI item price: UnitPrice={Unit}, TotalPrice={Total}", item.UnitPrice, item.TotalPrice);
return;
}
if (itemDto.IsSalesItem && itemDto.ManualUnitPrice.HasValue)
{
item.UnitPrice = itemDto.ManualUnitPrice.Value;
item.TotalPrice = itemDto.ManualUnitPrice.Value * itemDto.Quantity;
_logger.LogInformation("Sales item price: UnitPrice={Unit}, TotalPrice={Total}", item.UnitPrice, item.TotalPrice);
return;
}
if (itemDto.CatalogItemId.HasValue)
{
if (itemDto.Coats != null && itemDto.Coats.Any())
{
_logger.LogInformation("Calculating catalog item with {CoatCount} coats", itemDto.Coats.Count);
var itemPricing = await _pricingService.CalculateQuoteItemPriceAsync(itemDto, companyId, ovenRateOverride);
ApplyCalculatedPricing(item, itemPricing);
return;
}
var catalogItem = await _unitOfWork.CatalogItems.GetByIdAsync(itemDto.CatalogItemId.Value);
if (catalogItem != null)
{
var unitPrice = itemDto.PowderCostOverride is > 0
? itemDto.PowderCostOverride.Value
: catalogItem.DefaultPrice;
item.UnitPrice = unitPrice;
item.TotalPrice = unitPrice * itemDto.Quantity;
_logger.LogInformation("Catalog item no coats: UnitPrice={Unit}, TotalPrice={Total}", item.UnitPrice, item.TotalPrice);
}
return;
}
_logger.LogInformation("Calculating custom item with {CoatCount} coats", itemDto.Coats?.Count ?? 0);
var pricing = await _pricingService.CalculateQuoteItemPriceAsync(itemDto, companyId, ovenRateOverride);
ApplyCalculatedPricing(item, pricing);
}
private async Task<List<QuoteItemCoat>> BuildQuoteItemCoatsAsync(CreateQuoteItemDto itemDto, int companyId, DateTime createdAtUtc)
{
if (itemDto.Coats == null || itemDto.Coats.Count == 0)
return [];
var coats = new List<QuoteItemCoat>();
for (var coatIndex = 0; coatIndex < itemDto.Coats.Count; coatIndex++)
{
var coatDto = itemDto.Coats[coatIndex];
if (coatDto.AddAsIncoming && coatDto.CatalogItemId.HasValue && !coatDto.InventoryItemId.HasValue)
coatDto.InventoryItemId = await CreateIncomingInventoryItemAsync(coatDto, companyId);
var coat = BuildQuoteItemCoat(coatDto, companyId, createdAtUtc);
var coatPricing = await _pricingService.CalculateCoatPriceAsync(
coatDto,
itemDto.SurfaceAreaSqFt,
itemDto.Quantity,
coatIndex,
itemDto.EstimatedMinutes,
companyId);
coat.CoatMaterialCost = coatPricing.CoatMaterialCost;
coat.CoatLaborCost = coatPricing.CoatLaborCost;
coat.CoatTotalCost = coatPricing.CoatTotalCost;
coats.Add(coat);
}
return coats;
}
private static List<QuoteItemPrepService> BuildQuoteItemPrepServices(CreateQuoteItemDto itemDto, int companyId, DateTime createdAtUtc)
{
if (itemDto.PrepServices == null || itemDto.PrepServices.Count == 0)
return [];
return itemDto.PrepServices
.Select(ps => new QuoteItemPrepService
{
PrepServiceId = ps.PrepServiceId,
EstimatedMinutes = ps.EstimatedMinutes,
BlastSetupId = ps.BlastSetupId,
CompanyId = companyId,
CreatedAt = createdAtUtc
})
.ToList();
}
private static QuoteItem BuildQuoteItem(CreateQuoteItemDto itemDto, int quoteId, int companyId, DateTime createdAtUtc)
{
return new QuoteItem
{
QuoteId = quoteId,
Description = itemDto.Description,
Quantity = itemDto.Quantity,
SurfaceAreaSqFt = itemDto.SurfaceAreaSqFt,
CatalogItemId = itemDto.CatalogItemId,
IsGenericItem = itemDto.IsGenericItem,
ManualUnitPrice = itemDto.ManualUnitPrice,
PowderCostOverride = itemDto.PowderCostOverride,
IsLaborItem = itemDto.IsLaborItem,
IsSalesItem = itemDto.IsSalesItem,
Sku = itemDto.Sku,
RequiresSandblasting = itemDto.RequiresSandblasting,
RequiresMasking = itemDto.RequiresMasking,
EstimatedMinutes = itemDto.EstimatedMinutes,
IncludePrepCost = itemDto.IncludePrepCost,
Notes = itemDto.Notes,
Complexity = itemDto.Complexity,
IsAiItem = itemDto.IsAiItem,
AiTags = itemDto.AiTags,
AiPredictionId = itemDto.AiPredictionId,
CompanyId = companyId,
CreatedAt = createdAtUtc
};
}
private static QuoteItemCoat BuildQuoteItemCoat(CreateQuoteItemCoatDto coatDto, int companyId, DateTime createdAtUtc)
{
return new QuoteItemCoat
{
CoatName = coatDto.CoatName,
Sequence = coatDto.Sequence,
InventoryItemId = coatDto.InventoryItemId,
ColorName = coatDto.ColorName,
VendorId = coatDto.VendorId,
ColorCode = coatDto.ColorCode,
Finish = coatDto.Finish,
CoverageSqFtPerLb = coatDto.CoverageSqFtPerLb,
TransferEfficiency = coatDto.TransferEfficiency,
PowderCostPerLb = coatDto.PowderCostPerLb,
PowderToOrder = coatDto.PowderToOrder,
Notes = coatDto.Notes,
CompanyId = companyId,
CreatedAt = createdAtUtc
};
}
private static void ApplyCalculatedPricing(QuoteItem item, QuoteItemPricingResult pricing)
{
item.UnitPrice = pricing.UnitPrice;
item.TotalPrice = pricing.TotalPrice;
item.ItemMaterialCost = pricing.MaterialCost;
item.ItemLaborCost = pricing.LaborCost;
item.ItemEquipmentCost = pricing.EquipmentCost;
}
private async Task UpdateAiPredictionOverrideAsync(CreateQuoteItemDto itemDto, decimal finalUnitPrice)
{
if (!itemDto.AiPredictionId.HasValue) return;
var prediction = await _unitOfWork.AiItemPredictions.GetByIdAsync(itemDto.AiPredictionId.Value);
if (prediction == null) return;
var sqftDiff = Math.Abs(prediction.PredictedSurfaceAreaSqFt - itemDto.SurfaceAreaSqFt);
var priceDiff = Math.Abs(prediction.PredictedUnitPrice - finalUnitPrice);
prediction.UserOverrodeEstimate = sqftDiff > 0.01m || priceDiff > 0.01m;
prediction.UpdatedAt = DateTime.UtcNow;
}
private async Task<int?> CreateIncomingInventoryItemAsync(CreateQuoteItemCoatDto coatDto, int companyId)
{
try
{
var catalogItem = await _unitOfWork.PowderCatalog.GetByIdAsync(coatDto.CatalogItemId!.Value);
if (catalogItem == null) return null;
var categories = await _unitOfWork.InventoryCategoryLookups.GetAllAsync();
var coatingCategory = categories
.Where(c => c.IsActive && c.IsCoating)
.OrderBy(c => c.DisplayOrder)
.FirstOrDefault();
var vendors = await _unitOfWork.Vendors.GetAllAsync();
var vendorNameLower = catalogItem.VendorName.ToLower();
var matchedVendor = vendors.FirstOrDefault(v =>
v.CompanyName.ToLower().Contains(vendorNameLower) ||
vendorNameLower.Contains(v.CompanyName.ToLower()));
var code = coatingCategory != null
? (coatingCategory.CategoryCode.Length >= 4
? coatingCategory.CategoryCode[..4].ToUpperInvariant()
: coatingCategory.CategoryCode.ToUpperInvariant().PadRight(4, 'X'))
: "POWD";
var prefix = $"{code}-{DateTime.Now:yyMM}-";
var allItems = await _unitOfWork.InventoryItems.GetAllAsync(ignoreQueryFilters: true);
var maxSeq = allItems
.Where(i => i.SKU.StartsWith(prefix))
.Select(i => int.TryParse(i.SKU[prefix.Length..], out var n) ? n : 0)
.DefaultIfEmpty(0)
.Max();
var sku = $"{prefix}{(maxSeq + 1):D4}";
var name = System.Globalization.CultureInfo.CurrentCulture.TextInfo
.ToTitleCase(catalogItem.ColorName.Trim().ToLower());
var description = catalogItem.Description;
var finish = catalogItem.Finish;
var colorFamilies = catalogItem.ColorFamilies;
var cureTemp = catalogItem.CureTemperatureF;
var cureTime = catalogItem.CureTimeMinutes;
var coverage = catalogItem.CoverageSqFtPerLb;
var transferEff = catalogItem.TransferEfficiency;
var specificGravity = catalogItem.SpecificGravity;
var imageUrl = catalogItem.ImageUrl;
var sdsUrl = catalogItem.SdsUrl;
var tdsUrl = catalogItem.TdsUrl;
var needsAugment = !string.IsNullOrWhiteSpace(catalogItem.ProductUrl) &&
(string.IsNullOrWhiteSpace(description) ||
string.IsNullOrWhiteSpace(colorFamilies) ||
cureTemp == null || cureTime == null);
if (needsAugment)
{
try
{
var augmented = await _aiLookupService.LookupByUrlAsync(catalogItem.ProductUrl!, catalogItem.ColorName, catalogItem.TdsUrl);
if (augmented.Success)
{
description = string.IsNullOrWhiteSpace(description) ? augmented.Description : description;
finish = string.IsNullOrWhiteSpace(finish) ? augmented.Finish : finish;
colorFamilies = string.IsNullOrWhiteSpace(colorFamilies) ? augmented.ColorFamilies : colorFamilies;
cureTemp ??= augmented.CureTemperatureF;
cureTime ??= augmented.CureTimeMinutes;
coverage ??= augmented.CoverageSqFtPerLb;
transferEff ??= augmented.TransferEfficiency;
specificGravity ??= augmented.SpecificGravity;
imageUrl = string.IsNullOrWhiteSpace(imageUrl) ? augmented.ImageUrl : imageUrl;
sdsUrl = string.IsNullOrWhiteSpace(sdsUrl) ? augmented.SdsUrl : sdsUrl;
tdsUrl = string.IsNullOrWhiteSpace(tdsUrl) ? augmented.TdsUrl : tdsUrl;
_logger.LogInformation("AI-augmented incoming inventory item for catalog {CatalogId}", catalogItem.Id);
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "AI augment failed for catalog {CatalogId}, continuing with catalog data", catalogItem.Id);
}
}
var item = new InventoryItem
{
SKU = sku,
Name = name,
Description = description,
ColorName = catalogItem.ColorName,
Manufacturer = catalogItem.VendorName,
ManufacturerPartNumber = catalogItem.Sku,
Finish = finish,
ColorFamilies = colorFamilies,
RequiresClearCoat = catalogItem.RequiresClearCoat ?? false,
CoverageSqFtPerLb = coverage ?? 30m,
TransferEfficiency = transferEff ?? 65m,
CureTemperatureF = cureTemp,
CureTimeMinutes = cureTime,
SpecificGravity = specificGravity,
SpecPageUrl = catalogItem.ProductUrl,
ImageUrl = imageUrl,
SdsUrl = sdsUrl,
TdsUrl = tdsUrl,
UnitCost = catalogItem.UnitPrice,
AverageCost = catalogItem.UnitPrice,
LastPurchasePrice = catalogItem.UnitPrice,
QuantityOnHand = 0,
UnitOfMeasure = "lbs",
PrimaryVendorId = matchedVendor?.Id,
InventoryCategoryId = coatingCategory?.Id,
Category = coatingCategory?.DisplayName ?? "Powder Coating",
IsActive = true,
IsIncoming = true,
CompanyId = companyId,
CreatedAt = DateTime.UtcNow,
};
await _unitOfWork.InventoryItems.AddAsync(item);
await _unitOfWork.SaveChangesAsync();
coatDto.PowderCostPerLb = null;
_logger.LogInformation("Created incoming inventory item {Id} ({Name}) from catalog {CatalogId} via quote coat",
item.Id, item.Name, coatDto.CatalogItemId);
return item.Id;
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to create incoming inventory item from catalog {CatalogId}, continuing without inventory link",
coatDto.CatalogItemId);
return null;
}
}
}
@@ -122,6 +122,10 @@ public class BillPayment : BaseEntity
public string? CheckNumber { get; set; } public string? CheckNumber { get; set; }
public string? Memo { get; set; } public string? Memo { get; set; }
/// <summary>True once this payment has been matched against a bank statement during reconciliation.</summary>
public bool IsCleared { get; set; } = false;
public DateTime? ClearedDate { get; set; }
// Navigation // Navigation
public virtual Bill Bill { get; set; } = null!; public virtual Bill Bill { get; set; } = null!;
public virtual Vendor Vendor { get; set; } = null!; public virtual Vendor Vendor { get; set; } = null!;
@@ -150,9 +154,305 @@ public class Expense : BaseEntity
public string? Memo { get; set; } public string? Memo { get; set; }
public string? ReceiptFilePath { get; set; } public string? ReceiptFilePath { get; set; }
/// <summary>True once this expense has been matched against a bank statement during reconciliation.</summary>
public bool IsCleared { get; set; } = false;
public DateTime? ClearedDate { get; set; }
// Navigation // Navigation
public virtual Vendor? Vendor { get; set; } public virtual Vendor? Vendor { get; set; }
public virtual Account ExpenseAccount { get; set; } = null!; public virtual Account ExpenseAccount { get; set; } = null!;
public virtual Account PaymentAccount { get; set; } = null!; public virtual Account PaymentAccount { get; set; } = null!;
public virtual Job? Job { get; set; } public virtual Job? Job { get; set; }
} }
/// <summary>
/// Manual double-entry journal entry. Lines must balance (sum of debits == sum of credits)
/// before posting. Once posted the entry is immutable — use Reverse to correct it.
/// Entry numbering follows the pattern JE-YYMM-#### scoped per company.
/// </summary>
public class JournalEntry : BaseEntity
{
public string EntryNumber { get; set; } = string.Empty;
public DateTime EntryDate { get; set; } = DateTime.UtcNow;
public string? Reference { get; set; }
public string? Description { get; set; }
public JournalEntryStatus Status { get; set; } = JournalEntryStatus.Draft;
/// <summary>True if this entry was machine-generated as a reversal of another entry.</summary>
public bool IsReversal { get; set; } = false;
/// <summary>FK to the original entry being reversed. Null for normal entries.</summary>
public int? ReversalOfId { get; set; }
public DateTime? PostedAt { get; set; }
public string? PostedBy { get; set; }
// Navigation
public virtual ICollection<JournalEntryLine> Lines { get; set; } = new List<JournalEntryLine>();
public virtual JournalEntry? ReversalOf { get; set; }
}
/// <summary>
/// One debit or credit line within a <see cref="JournalEntry"/>. Either DebitAmount or CreditAmount
/// should be non-zero per line (not both). LineOrder controls display sequence.
/// </summary>
public class JournalEntryLine : BaseEntity
{
public int JournalEntryId { get; set; }
public int AccountId { get; set; }
public decimal DebitAmount { get; set; }
public decimal CreditAmount { get; set; }
public string? Description { get; set; }
public int LineOrder { get; set; }
// Navigation
public virtual JournalEntry JournalEntry { get; set; } = null!;
public virtual Account Account { get; set; } = null!;
}
/// <summary>
/// A bank reconciliation session for a single bank/cash account against a statement.
/// Cleared balance = BeginningBalance + cleared deposits - cleared payments.
/// The reconciliation is complete when Difference (EndingBalance - ClearedBalance) == 0.
/// </summary>
public class BankReconciliation : BaseEntity
{
/// <summary>Must be a bank/cash subtype account.</summary>
public int AccountId { get; set; }
public DateTime StatementDate { get; set; }
public decimal BeginningBalance { get; set; }
public decimal EndingBalance { get; set; }
public BankReconciliationStatus Status { get; set; } = BankReconciliationStatus.InProgress;
public DateTime? CompletedAt { get; set; }
public string? CompletedBy { get; set; }
public string? Notes { get; set; }
// Navigation
public virtual Account Account { get; set; } = null!;
}
/// <summary>
/// A credit note received from a vendor (returned goods, pricing dispute, short-ship).
/// Reduces Accounts Payable and reverses the original expense/COGS when posted.
/// Numbering: VC-YYMM-####
/// </summary>
public class VendorCredit : BaseEntity
{
public string CreditNumber { get; set; } = string.Empty;
public int VendorId { get; set; }
/// <summary>AP account this credit reduces (default: Accounts Payable 2000).</summary>
public int APAccountId { get; set; }
public DateTime CreditDate { get; set; } = DateTime.UtcNow;
public VendorCreditStatus Status { get; set; } = VendorCreditStatus.Open;
public decimal Total { get; set; }
public decimal RemainingAmount { get; set; }
public string? Memo { get; set; }
/// <summary>Set by Post() when GL entries are made (DR AP / CR expense lines). Null = unposted.</summary>
public DateTime? PostedDate { get; set; }
// Navigation
public virtual Vendor Vendor { get; set; } = null!;
public virtual Account APAccount { get; set; } = null!;
public virtual ICollection<VendorCreditLineItem> LineItems { get; set; } = new List<VendorCreditLineItem>();
public virtual ICollection<VendorCreditApplication> Applications { get; set; } = new List<VendorCreditApplication>();
}
/// <summary>
/// A single line on a vendor credit, each reversing a specific expense/COGS account.
/// </summary>
public class VendorCreditLineItem : BaseEntity
{
public int VendorCreditId { get; set; }
/// <summary>Expense/COGS account being reversed by this line.</summary>
public int? AccountId { get; set; }
public string Description { get; set; } = string.Empty;
public decimal Amount { get; set; }
// Navigation
public virtual VendorCredit VendorCredit { get; set; } = null!;
public virtual Account? Account { get; set; }
}
/// <summary>
/// Records the application of a vendor credit against a specific vendor bill.
/// No additional GL posting is needed — AP was already adjusted when the credit was posted.
/// </summary>
public class VendorCreditApplication : BaseEntity
{
public int VendorCreditId { get; set; }
public int BillId { get; set; }
public decimal Amount { get; set; }
public DateTime AppliedDate { get; set; } = DateTime.UtcNow;
// Navigation
public virtual VendorCredit VendorCredit { get; set; } = null!;
public virtual Bill Bill { get; set; } = null!;
}
/// <summary>
/// A saved recipe for a document that should be automatically created on a recurring schedule.
/// The <see cref="TemplateData"/> column stores a JSON blob whose schema depends on
/// <see cref="TemplateType"/>: see <c>RecurringTransactionService</c> for the exact shape.
/// <para>
/// Bills are created as Draft so the user can review before posting.
/// Expenses are created immediately (already-paid transactions).
/// </para>
/// Numbering: REC-YYMM-####
/// </summary>
public class RecurringTemplate : BaseEntity
{
public string Name { get; set; } = string.Empty;
public RecurringTemplateType TemplateType { get; set; }
public RecurringFrequency Frequency { get; set; }
/// <summary>Every N periods. E.g. Frequency=Monthly, IntervalCount=3 → quarterly.</summary>
public int IntervalCount { get; set; } = 1;
/// <summary>UTC date when the template will next fire. Set to the desired first occurrence date on creation.</summary>
public DateTime NextFireDate { get; set; }
/// <summary>Optional UTC date after which no further occurrences are generated.</summary>
public DateTime? EndDate { get; set; }
/// <summary>Optional hard cap on total occurrences. Null = unlimited.</summary>
public int? MaxOccurrences { get; set; }
/// <summary>How many documents have been generated so far.</summary>
public int OccurrenceCount { get; set; }
public bool IsActive { get; set; } = true;
/// <summary>JSON payload whose schema matches the TemplateType. See RecurringTransactionService.</summary>
public string TemplateData { get; set; } = "{}";
/// <summary>Last error from the background service, cleared on next successful fire.</summary>
public string? LastError { get; set; }
}
/// <summary>
/// A named tax rate (e.g., "CA Sales Tax 8.25%") used to pre-fill the TaxPercent field on
/// invoices when a taxable customer is selected. Companies can define multiple rates for
/// different jurisdictions and mark one as default.
/// </summary>
public class TaxRate : BaseEntity
{
public string Name { get; set; } = string.Empty;
/// <summary>Rate as a percentage, e.g., 8.25 means 8.25%.</summary>
public decimal Rate { get; set; }
public string? State { get; set; }
public string? Description { get; set; }
/// <summary>When true, this rate is auto-applied to new invoices for taxable customers.</summary>
public bool IsDefault { get; set; }
public bool IsActive { get; set; } = true;
}
/// <summary>
/// A depreciable fixed asset (oven, blast cabinet, spray booth, vehicle, etc.).
/// Stores straight-line depreciation parameters and links to the three GL accounts needed
/// to auto-post monthly depreciation journal entries.
/// </summary>
public class FixedAsset : BaseEntity
{
public string Name { get; set; } = string.Empty;
public string? Description { get; set; }
public DateTime PurchaseDate { get; set; }
public decimal PurchaseCost { get; set; }
/// <summary>Residual value at end of useful life (often $0 for shop equipment).</summary>
public decimal SalvageValue { get; set; } = 0;
/// <summary>Total depreciation period in months (e.g., 60 = 5 years).</summary>
public int UsefulLifeMonths { get; set; }
/// <summary>Running total of depreciation posted so far.</summary>
public decimal AccumulatedDepreciation { get; set; } = 0;
public bool IsDisposed { get; set; } = false;
public DateTime? DisposalDate { get; set; }
// Computed — not persisted
/// <summary>Current net book value: PurchaseCost minus AccumulatedDepreciation.</summary>
public decimal BookValue => PurchaseCost - AccumulatedDepreciation;
/// <summary>Straight-line monthly depreciation amount.</summary>
public decimal MonthlyDepreciation => UsefulLifeMonths > 0
? Math.Round((PurchaseCost - SalvageValue) / UsefulLifeMonths, 2) : 0;
// GL account links — all optional; assets without accounts can be tracked but not auto-posted
/// <summary>Balance Sheet FixedAsset account (debited when asset is purchased).</summary>
public int? AssetAccountId { get; set; }
/// <summary>P&L Depreciation Expense account (debited each period).</summary>
public int? DepreciationExpenseAccountId { get; set; }
/// <summary>Balance Sheet Accumulated Depreciation account (credited each period).</summary>
public int? AccumDepreciationAccountId { get; set; }
// Navigation
public virtual Account? AssetAccount { get; set; }
public virtual Account? DepreciationExpenseAccount { get; set; }
public virtual Account? AccumDepreciationAccount { get; set; }
public virtual ICollection<FixedAssetDepreciationEntry> DepreciationEntries { get; set; } = new List<FixedAssetDepreciationEntry>();
}
/// <summary>
/// Records each periodic depreciation posting for a fixed asset. One record per asset per
/// month/year combination; linked to the JournalEntry that was created so the posting
/// can be traced back through the GL.
/// </summary>
public class FixedAssetDepreciationEntry : BaseEntity
{
public int FixedAssetId { get; set; }
public int PeriodYear { get; set; }
public int PeriodMonth { get; set; }
public decimal Amount { get; set; }
/// <summary>The JE that was posted for this depreciation period (null if manually recorded).</summary>
public int? JournalEntryId { get; set; }
// Navigation
public virtual FixedAsset FixedAsset { get; set; } = null!;
public virtual JournalEntry? JournalEntry { get; set; }
}
/// <summary>
/// A named annual budget. Contains one BudgetLine per account per month. Supports
/// multiple budgets per fiscal year (e.g. "Conservative" vs "Optimistic") but only
/// one is marked IsDefault for the Budget vs. Actual report.
/// </summary>
public class Budget : BaseEntity
{
public string Name { get; set; } = string.Empty;
public int FiscalYear { get; set; }
public string? Notes { get; set; }
public bool IsDefault { get; set; } = false;
public virtual ICollection<BudgetLine> Lines { get; set; } = new List<BudgetLine>();
}
/// <summary>
/// Monthly budget amount for one account within a Budget. JanDec stored as separate
/// columns so the grid editor can write them in a single POST without a line-item loop.
/// Annual is a computed property summing all twelve months.
/// </summary>
public class BudgetLine : BaseEntity
{
public int BudgetId { get; set; }
public int AccountId { get; set; }
public decimal Jan { get; set; }
public decimal Feb { get; set; }
public decimal Mar { get; set; }
public decimal Apr { get; set; }
public decimal May { get; set; }
public decimal Jun { get; set; }
public decimal Jul { get; set; }
public decimal Aug { get; set; }
public decimal Sep { get; set; }
public decimal Oct { get; set; }
public decimal Nov { get; set; }
public decimal Dec { get; set; }
public decimal Annual => Jan + Feb + Mar + Apr + May + Jun + Jul + Aug + Sep + Oct + Nov + Dec;
public virtual Budget Budget { get; set; } = null!;
public virtual Account Account { get; set; } = null!;
}
/// <summary>
/// Records a completed year-end close. The close posts a JE that zeroes all
/// Revenue and Expense account balances into Retained Earnings, and marks
/// the year as closed so it cannot be closed again.
/// </summary>
public class YearEndClose : BaseEntity
{
public int ClosedYear { get; set; }
public DateTime ClosedAt { get; set; } = DateTime.UtcNow;
public string? ClosedBy { get; set; }
public int JournalEntryId { get; set; }
public virtual JournalEntry JournalEntry { get; set; } = null!;
}
@@ -50,6 +50,8 @@ public class ApplicationUser : IdentityUser
public bool CanManageMaintenance { get; set; } = false; public bool CanManageMaintenance { get; set; } = false;
public bool CanManageInvoices { get; set; } = false; public bool CanManageInvoices { get; set; } = false;
public bool CanViewReports { 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) // Profile Photo (filesystem storage)
public string? ProfilePictureFilePath { get; set; } // Relative path from ContentRoot/media/ (e.g., "123/profile-photos/user-abc.jpg") public string? ProfilePictureFilePath { get; set; } // Relative path from ContentRoot/media/ (e.g., "123/profile-photos/user-abc.jpg")
@@ -105,6 +105,19 @@ public class Company : BaseEntity
public bool MarketingEmailOptOut { get; set; } = false; public bool MarketingEmailOptOut { get; set; } = false;
public string MarketingUnsubscribeToken { get; set; } = Guid.NewGuid().ToString("N"); public string MarketingUnsubscribeToken { get; set; } = Guid.NewGuid().ToString("N");
/// <summary>
/// Determines whether financial reports (P&amp;L, Balance Sheet, Cash Flow) use
/// cash-basis or accrual-basis presentation. Switchable at any time — no GL
/// re-posting occurs. Default is Accrual (standard for most businesses).
/// </summary>
public AccountingMethod AccountingMethod { get; set; } = AccountingMethod.Accrual;
/// <summary>
/// When set, prevents creating or editing accounting entries (JEs, bills, expenses) with dates
/// on or before this date. Protects closed periods from accidental backdating. Null = no lock.
/// </summary>
public DateTime? BookLockedThrough { get; set; }
// Settings // Settings
public string? TimeZone { get; set; } = "America/New_York"; public string? TimeZone { get; set; } = "America/New_York";
public byte[]? LogoData { get; set; } // Legacy - kept for backward compatibility public byte[]? LogoData { get; set; } // Legacy - kept for backward compatibility
@@ -15,6 +15,10 @@ public class Deposit : BaseEntity
public string? Notes { get; set; } public string? Notes { get; set; }
public string? RecordedById { 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 // Applied to invoice when invoice is created
public int? AppliedToInvoiceId { get; set; } public int? AppliedToInvoiceId { get; set; }
public DateTime? AppliedDate { get; set; } public DateTime? AppliedDate { get; set; }
@@ -42,6 +42,19 @@ public class Invoice : BaseEntity
public string? Terms { get; set; } public string? Terms { get; set; }
public string? CustomerPO { 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> /// <summary>
/// Original invoice number from an external system (e.g. QuickBooks invoice # "3048"). /// Original invoice number from an external system (e.g. QuickBooks invoice # "3048").
/// Stored for searchability and traceability after import. Searchable from the invoice list. /// Stored for searchability and traceability after import. Searchable from the invoice list.
+1
View File
@@ -28,6 +28,7 @@ public class Job : BaseEntity
// Pricing // Pricing
public decimal QuotedPrice { get; set; } public decimal QuotedPrice { get; set; }
public decimal FinalPrice { get; set; } public decimal FinalPrice { get; set; }
public decimal OvenBatchCost { get; set; }
public decimal ShopSuppliesAmount { get; set; } public decimal ShopSuppliesAmount { get; set; }
public decimal ShopSuppliesPercent { get; set; } public decimal ShopSuppliesPercent { get; set; }
@@ -9,6 +9,8 @@ public class JobTemplateItem : BaseEntity
public int? CatalogItemId { get; set; } public int? CatalogItemId { get; set; }
public bool IsGenericItem { get; set; } public bool IsGenericItem { get; set; }
public bool IsLaborItem { get; set; } public bool IsLaborItem { get; set; }
public bool IsSalesItem { get; set; }
public string? Sku { get; set; }
public decimal? ManualUnitPrice { get; set; } public decimal? ManualUnitPrice { get; set; }
public bool RequiresSandblasting { get; set; } public bool RequiresSandblasting { get; set; }
public bool RequiresMasking { get; set; } public bool RequiresMasking { get; set; }
@@ -18,6 +18,10 @@ public class Payment : BaseEntity
/// </summary> /// </summary>
public int? DepositAccountId { get; set; } public int? DepositAccountId { get; set; }
/// <summary>True once this payment has been matched against a bank statement during reconciliation.</summary>
public bool IsCleared { get; set; } = false;
public DateTime? ClearedDate { get; set; }
// Navigation // Navigation
public virtual Invoice Invoice { get; set; } = null!; public virtual Invoice Invoice { get; set; } = null!;
public virtual ApplicationUser? RecordedBy { get; set; } public virtual ApplicationUser? RecordedBy { get; set; }
@@ -22,6 +22,10 @@ public class Refund : BaseEntity
public DateTime? IssuedDate { get; set; } public DateTime? IssuedDate { get; set; }
public string? IssuedById { 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 // For store-credit refunds: the CreditMemo created on their behalf
public int? CreditMemoId { get; set; } 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> /// <summary>Default expense account pre-filled on new bill line items for this vendor.</summary>
public int? DefaultExpenseAccountId { get; set; } 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 // Navigation
public virtual ICollection<InventoryItem> InventoryItems { get; set; } = new List<InventoryItem>(); public virtual ICollection<InventoryItem> InventoryItems { get; set; } = new List<InventoryItem>();
public virtual ICollection<Bill> Bills { get; set; } = new List<Bill>(); public virtual ICollection<Bill> Bills { get; set; } = new List<Bill>();
@@ -66,3 +66,61 @@ public enum BillStatus
Paid = 3, Paid = 3,
Voided = 4 Voided = 4
} }
/// <summary>
/// Company-level accounting method preference. Affects how financial reports
/// (P&amp;L, Balance Sheet, Cash Flow) query and present data. Switching this
/// setting never re-posts historical GL entries — it is a report-time choice only.
/// </summary>
public enum AccountingMethod
{
/// <summary>Revenue and expenses recognised when cash changes hands.</summary>
Cash = 0,
/// <summary>Revenue and expenses recognised when earned/incurred (default).</summary>
Accrual = 1
}
public enum BankReconciliationStatus
{
InProgress = 0,
Completed = 1
}
public enum VendorCreditStatus
{
Open = 0,
PartiallyApplied = 1,
Applied = 2,
Voided = 3
}
/// <summary>Source document type for a recurring template — controls which entity is created on each fire.</summary>
public enum RecurringTemplateType
{
/// <summary>Creates a vendor Bill (Draft, pending user review).</summary>
Bill = 1,
/// <summary>Creates a direct Expense entry (immediately recorded).</summary>
Expense = 2
}
/// <summary>How often a recurring template fires.</summary>
public enum RecurringFrequency
{
Daily = 1,
Weekly = 2,
BiWeekly = 3,
Monthly = 4,
Quarterly = 5,
Annually = 6
}
/// <summary>Lifecycle state of a Manual Journal Entry.</summary>
public enum JournalEntryStatus
{
/// <summary>Not yet posted — can still be edited or deleted.</summary>
Draft = 0,
/// <summary>Posted to the GL — immutable; can only be reversed.</summary>
Posted = 1,
/// <summary>A reversal JE has been created and posted for this entry.</summary>
Reversed = 2
}
@@ -91,6 +91,35 @@ public interface IUnitOfWork : IDisposable
IRepository<BillPayment> BillPayments { get; } IRepository<BillPayment> BillPayments { get; }
IRepository<Expense> Expenses { get; } IRepository<Expense> Expenses { get; }
// Manual Journal Entries
IRepository<JournalEntry> JournalEntries { get; }
IRepository<JournalEntryLine> JournalEntryLines { get; }
// Vendor Credits
IRepository<VendorCredit> VendorCredits { get; }
IRepository<VendorCreditLineItem> VendorCreditLineItems { get; }
IRepository<VendorCreditApplication> VendorCreditApplications { get; }
// Bank Reconciliation
IRepository<BankReconciliation> BankReconciliations { get; }
// Tax Rates
IRepository<TaxRate> TaxRates { get; }
// Recurring Transactions
IRepository<RecurringTemplate> RecurringTemplates { get; }
// Fixed Assets
IRepository<FixedAsset> FixedAssets { get; }
IRepository<FixedAssetDepreciationEntry> FixedAssetDepreciationEntries { get; }
// Budgeting
IRepository<Budget> Budgets { get; }
IRepository<BudgetLine> BudgetLines { get; }
// Year-End Close
IRepository<YearEndClose> YearEndCloses { get; }
// Notifications — typed repository for IgnoreQueryFilters-based history lookups // Notifications — typed repository for IgnoreQueryFilters-based history lookups
INotificationLogRepository NotificationLogs { get; } INotificationLogRepository NotificationLogs { get; }
IRepository<NotificationTemplate> NotificationTemplates { get; } IRepository<NotificationTemplate> NotificationTemplates { get; }
@@ -9,12 +9,17 @@ public record CompanyWizardInfo(bool Completed, DateTime? CompletedAt, string? C
/// <summary> /// <summary>
/// Per-company entity count summary used to populate the Index list without N+1 round-trips. /// 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> /// </summary>
public record CompanyCountSummary( public record CompanyCountSummary(
IReadOnlyDictionary<int, int> JobCounts, IReadOnlyDictionary<int, int> JobCounts,
IReadOnlyDictionary<int, int> QuoteCounts, IReadOnlyDictionary<int, int> QuoteCounts,
IReadOnlyDictionary<int, int> CustomerCounts, 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> /// <summary>
@@ -324,6 +324,39 @@ public class ApplicationDbContext : IdentityDbContext<ApplicationUser>, IDataPro
/// <summary>Ad-hoc expense records (non-bill spending); tenant-filtered with soft delete.</summary> /// <summary>Ad-hoc expense records (non-bill spending); tenant-filtered with soft delete.</summary>
public DbSet<Expense> Expenses { get; set; } public DbSet<Expense> Expenses { get; set; }
/// <summary>Manual double-entry journal entries (Draft/Posted/Reversed lifecycle); tenant-filtered with soft delete.</summary>
public DbSet<JournalEntry> JournalEntries { get; set; }
/// <summary>Individual debit/credit lines within a journal entry; soft-delete only (access controlled through parent JournalEntry).</summary>
public DbSet<JournalEntryLine> JournalEntryLines { get; set; }
/// <summary>Bank reconciliation sessions matching GL transactions to bank statements; tenant-filtered with soft delete.</summary>
public DbSet<BankReconciliation> BankReconciliations { get; set; }
/// <summary>Named tax rates used to pre-fill invoice tax percent by jurisdiction; tenant-filtered with soft delete.</summary>
public DbSet<TaxRate> TaxRates { get; set; }
/// <summary>Recurring transaction templates that auto-generate bills or expenses on a schedule; tenant-filtered with soft delete.</summary>
public DbSet<RecurringTemplate> RecurringTemplates { get; set; }
/// <summary>Fixed assets subject to straight-line depreciation; tenant-filtered with soft delete.</summary>
public DbSet<FixedAsset> FixedAssets { get; set; }
/// <summary>One record per asset per period for each depreciation posting; soft-delete only.</summary>
public DbSet<FixedAssetDepreciationEntry> FixedAssetDepreciationEntries { get; set; }
/// <summary>Named annual budgets with monthly amounts per GL account; tenant-filtered with soft delete.</summary>
public DbSet<Budget> Budgets { get; set; }
/// <summary>One row per account per Budget; contains JanDec decimal columns.</summary>
public DbSet<BudgetLine> BudgetLines { get; set; }
/// <summary>Audit trail of completed year-end closes; tenant-filtered with soft delete.</summary>
public DbSet<YearEndClose> YearEndCloses { get; set; }
/// <summary>Credit notes received from vendors (returned goods, pricing disputes); tenant-filtered with soft delete.</summary>
public DbSet<VendorCredit> VendorCredits { get; set; }
/// <summary>Expense-reversal line items on a vendor credit; soft-delete only.</summary>
public DbSet<VendorCreditLineItem> VendorCreditLineItems { get; set; }
/// <summary>Application records linking a vendor credit to a specific bill; soft-delete only.</summary>
public DbSet<VendorCreditApplication> VendorCreditApplications { get; set; }
// Job Templates // Job Templates
/// <summary>Reusable job templates that pre-populate job items, coats, and prep services on job creation.</summary> /// <summary>Reusable job templates that pre-populate job items, coats, and prep services on job creation.</summary>
public DbSet<JobTemplate> JobTemplates { get; set; } public DbSet<JobTemplate> JobTemplates { get; set; }
@@ -614,6 +647,93 @@ public class ApplicationDbContext : IdentityDbContext<ApplicationUser>, IDataPro
modelBuilder.Entity<Expense>().HasQueryFilter(e => modelBuilder.Entity<Expense>().HasQueryFilter(e =>
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId)); !e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
// Journal Entries: tenant-filtered; lines use soft-delete only (child rows)
modelBuilder.Entity<JournalEntry>().HasQueryFilter(e =>
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
modelBuilder.Entity<JournalEntryLine>().HasQueryFilter(e => !e.IsDeleted);
// Bank Reconciliation: tenant-filtered
modelBuilder.Entity<BankReconciliation>().HasQueryFilter(e =>
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
// Tax Rates: tenant-filtered
modelBuilder.Entity<TaxRate>().HasQueryFilter(e =>
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
// Recurring Templates: tenant-filtered
modelBuilder.Entity<RecurringTemplate>().HasQueryFilter(e =>
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
// Fixed Assets: tenant-filtered with soft delete; depreciation entries soft-delete only
modelBuilder.Entity<FixedAsset>().HasQueryFilter(e =>
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
modelBuilder.Entity<FixedAssetDepreciationEntry>().HasQueryFilter(e => !e.IsDeleted);
// FixedAsset → Account (three FKs): NoAction to avoid cascade conflicts; Account has no
// reverse collection for FixedAssets so WithMany() is anonymous for each.
modelBuilder.Entity<FixedAsset>()
.HasOne(fa => fa.AssetAccount)
.WithMany()
.HasForeignKey(fa => fa.AssetAccountId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<FixedAsset>()
.HasOne(fa => fa.DepreciationExpenseAccount)
.WithMany()
.HasForeignKey(fa => fa.DepreciationExpenseAccountId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<FixedAsset>()
.HasOne(fa => fa.AccumDepreciationAccount)
.WithMany()
.HasForeignKey(fa => fa.AccumDepreciationAccountId)
.OnDelete(DeleteBehavior.Restrict);
// FixedAssetDepreciationEntry → JournalEntry: NoAction (entries outlive their JE)
modelBuilder.Entity<FixedAssetDepreciationEntry>()
.HasOne(e => e.JournalEntry)
.WithMany()
.HasForeignKey(e => e.JournalEntryId)
.OnDelete(DeleteBehavior.NoAction);
// Budgets: tenant-filtered; BudgetLines soft-delete only
modelBuilder.Entity<Budget>().HasQueryFilter(e =>
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
modelBuilder.Entity<BudgetLine>().HasQueryFilter(e => !e.IsDeleted);
// BudgetLine → Account: Restrict delete so removing an account doesn't cascade into budget data
modelBuilder.Entity<BudgetLine>()
.HasOne(bl => bl.Account)
.WithMany()
.HasForeignKey(bl => bl.AccountId)
.OnDelete(DeleteBehavior.Restrict);
// YearEndClose: tenant-filtered; links to a specific JE
modelBuilder.Entity<YearEndClose>().HasQueryFilter(e =>
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
modelBuilder.Entity<YearEndClose>()
.HasOne(y => y.JournalEntry)
.WithMany()
.HasForeignKey(y => y.JournalEntryId)
.OnDelete(DeleteBehavior.Restrict);
// Vendor Credits: tenant-filtered; child rows soft-delete only
modelBuilder.Entity<VendorCredit>().HasQueryFilter(e =>
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
modelBuilder.Entity<VendorCreditLineItem>().HasQueryFilter(e => !e.IsDeleted);
modelBuilder.Entity<VendorCreditApplication>().HasQueryFilter(e => !e.IsDeleted);
// VendorCreditApplication: NoAction on both FKs to avoid SQL Server multiple-cascade-path error 1785.
// Bills and VendorCredits both cascade-delete through Vendor, creating two paths to VendorCreditApplications.
modelBuilder.Entity<VendorCreditApplication>()
.HasOne(vca => vca.Bill)
.WithMany()
.HasForeignKey(vca => vca.BillId)
.OnDelete(DeleteBehavior.NoAction);
modelBuilder.Entity<VendorCreditApplication>()
.HasOne(vca => vca.VendorCredit)
.WithMany(vc => vc.Applications)
.HasForeignKey(vca => vca.VendorCreditId)
.OnDelete(DeleteBehavior.NoAction);
// Purchase Orders // Purchase Orders
modelBuilder.Entity<PurchaseOrder>().HasQueryFilter(e => modelBuilder.Entity<PurchaseOrder>().HasQueryFilter(e =>
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId)); !e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
@@ -633,6 +753,34 @@ public class ApplicationDbContext : IdentityDbContext<ApplicationUser>, IDataPro
.HasForeignKey(a => a.ParentAccountId) .HasForeignKey(a => a.ParentAccountId)
.OnDelete(DeleteBehavior.Restrict); .OnDelete(DeleteBehavior.Restrict);
// JournalEntry self-referencing reversal link
modelBuilder.Entity<JournalEntry>()
.HasOne(je => je.ReversalOf)
.WithMany()
.HasForeignKey(je => je.ReversalOfId)
.OnDelete(DeleteBehavior.Restrict);
// BankReconciliation → Account (no cascade)
modelBuilder.Entity<BankReconciliation>()
.HasOne(br => br.Account)
.WithMany()
.HasForeignKey(br => br.AccountId)
.OnDelete(DeleteBehavior.Restrict);
// VendorCredit → APAccount (no cascade)
modelBuilder.Entity<VendorCredit>()
.HasOne(vc => vc.APAccount)
.WithMany()
.HasForeignKey(vc => vc.APAccountId)
.OnDelete(DeleteBehavior.Restrict);
// VendorCreditLineItem → Account (nullable, no cascade)
modelBuilder.Entity<VendorCreditLineItem>()
.HasOne(li => li.Account)
.WithMany()
.HasForeignKey(li => li.AccountId)
.OnDelete(DeleteBehavior.Restrict);
// Vendor → DefaultExpenseAccount (no cascade) // Vendor → DefaultExpenseAccount (no cascade)
modelBuilder.Entity<Vendor>() modelBuilder.Entity<Vendor>()
.HasOne(s => s.DefaultExpenseAccount) .HasOne(s => s.DefaultExpenseAccount)
@@ -0,0 +1,82 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace PowderCoating.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class AddJobTemplateItemSalesFields : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "IsSalesItem",
table: "JobTemplateItems",
type: "bit",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<string>(
name: "Sku",
table: "JobTemplateItems",
type: "nvarchar(max)",
nullable: true);
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 1,
column: "CreatedAt",
value: new DateTime(2026, 5, 10, 1, 12, 48, 386, DateTimeKind.Utc).AddTicks(2249));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 2,
column: "CreatedAt",
value: new DateTime(2026, 5, 10, 1, 12, 48, 386, DateTimeKind.Utc).AddTicks(2260));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 3,
column: "CreatedAt",
value: new DateTime(2026, 5, 10, 1, 12, 48, 386, DateTimeKind.Utc).AddTicks(2261));
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "IsSalesItem",
table: "JobTemplateItems");
migrationBuilder.DropColumn(
name: "Sku",
table: "JobTemplateItems");
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 1,
column: "CreatedAt",
value: new DateTime(2026, 5, 8, 14, 21, 51, 589, DateTimeKind.Utc).AddTicks(4358));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 2,
column: "CreatedAt",
value: new DateTime(2026, 5, 8, 14, 21, 51, 589, DateTimeKind.Utc).AddTicks(4424));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 3,
column: "CreatedAt",
value: new DateTime(2026, 5, 8, 14, 21, 51, 589, DateTimeKind.Utc).AddTicks(4426));
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,72 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace PowderCoating.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class AddAccountingMethod : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<int>(
name: "AccountingMethod",
table: "Companies",
type: "int",
nullable: false,
defaultValue: 1); // 1 = Accrual (default for new and existing companies)
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 1,
column: "CreatedAt",
value: new DateTime(2026, 5, 10, 3, 25, 9, 644, DateTimeKind.Utc).AddTicks(9957));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 2,
column: "CreatedAt",
value: new DateTime(2026, 5, 10, 3, 25, 9, 644, DateTimeKind.Utc).AddTicks(9963));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 3,
column: "CreatedAt",
value: new DateTime(2026, 5, 10, 3, 25, 9, 644, DateTimeKind.Utc).AddTicks(9965));
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "AccountingMethod",
table: "Companies");
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 1,
column: "CreatedAt",
value: new DateTime(2026, 5, 10, 1, 12, 48, 386, DateTimeKind.Utc).AddTicks(2249));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 2,
column: "CreatedAt",
value: new DateTime(2026, 5, 10, 1, 12, 48, 386, DateTimeKind.Utc).AddTicks(2260));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 3,
column: "CreatedAt",
value: new DateTime(2026, 5, 10, 1, 12, 48, 386, DateTimeKind.Utc).AddTicks(2261));
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,155 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace PowderCoating.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class AddJournalEntries : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "JournalEntries",
columns: table => new
{
Id = table.Column<int>(type: "int", nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
EntryNumber = table.Column<string>(type: "nvarchar(max)", nullable: false),
EntryDate = table.Column<DateTime>(type: "datetime2", nullable: false),
Reference = table.Column<string>(type: "nvarchar(max)", nullable: true),
Description = table.Column<string>(type: "nvarchar(max)", nullable: true),
Status = table.Column<int>(type: "int", nullable: false),
IsReversal = table.Column<bool>(type: "bit", nullable: false),
ReversalOfId = table.Column<int>(type: "int", nullable: true),
PostedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
PostedBy = 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_JournalEntries", x => x.Id);
table.ForeignKey(
name: "FK_JournalEntries_JournalEntries_ReversalOfId",
column: x => x.ReversalOfId,
principalTable: "JournalEntries",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
});
migrationBuilder.CreateTable(
name: "JournalEntryLines",
columns: table => new
{
Id = table.Column<int>(type: "int", nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
JournalEntryId = table.Column<int>(type: "int", nullable: false),
AccountId = table.Column<int>(type: "int", nullable: false),
DebitAmount = table.Column<decimal>(type: "decimal(18,2)", nullable: false),
CreditAmount = table.Column<decimal>(type: "decimal(18,2)", nullable: false),
Description = table.Column<string>(type: "nvarchar(max)", nullable: true),
LineOrder = 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_JournalEntryLines", x => x.Id);
table.ForeignKey(
name: "FK_JournalEntryLines_Accounts_AccountId",
column: x => x.AccountId,
principalTable: "Accounts",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_JournalEntryLines_JournalEntries_JournalEntryId",
column: x => x.JournalEntryId,
principalTable: "JournalEntries",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 1,
column: "CreatedAt",
value: new DateTime(2026, 5, 10, 3, 45, 31, 524, DateTimeKind.Utc).AddTicks(9350));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 2,
column: "CreatedAt",
value: new DateTime(2026, 5, 10, 3, 45, 31, 524, DateTimeKind.Utc).AddTicks(9357));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 3,
column: "CreatedAt",
value: new DateTime(2026, 5, 10, 3, 45, 31, 524, DateTimeKind.Utc).AddTicks(9359));
migrationBuilder.CreateIndex(
name: "IX_JournalEntries_ReversalOfId",
table: "JournalEntries",
column: "ReversalOfId");
migrationBuilder.CreateIndex(
name: "IX_JournalEntryLines_AccountId",
table: "JournalEntryLines",
column: "AccountId");
migrationBuilder.CreateIndex(
name: "IX_JournalEntryLines_JournalEntryId",
table: "JournalEntryLines",
column: "JournalEntryId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "JournalEntryLines");
migrationBuilder.DropTable(
name: "JournalEntries");
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 1,
column: "CreatedAt",
value: new DateTime(2026, 5, 10, 3, 25, 9, 644, DateTimeKind.Utc).AddTicks(9957));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 2,
column: "CreatedAt",
value: new DateTime(2026, 5, 10, 3, 25, 9, 644, DateTimeKind.Utc).AddTicks(9963));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 3,
column: "CreatedAt",
value: new DateTime(2026, 5, 10, 3, 25, 9, 644, DateTimeKind.Utc).AddTicks(9965));
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,212 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace PowderCoating.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class AddVendorCredits : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "VendorCredits",
columns: table => new
{
Id = table.Column<int>(type: "int", nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
CreditNumber = table.Column<string>(type: "nvarchar(max)", nullable: false),
VendorId = table.Column<int>(type: "int", nullable: false),
APAccountId = table.Column<int>(type: "int", nullable: false),
CreditDate = table.Column<DateTime>(type: "datetime2", nullable: false),
Status = table.Column<int>(type: "int", nullable: false),
Total = table.Column<decimal>(type: "decimal(18,2)", nullable: false),
RemainingAmount = table.Column<decimal>(type: "decimal(18,2)", nullable: false),
Memo = 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_VendorCredits", x => x.Id);
table.ForeignKey(
name: "FK_VendorCredits_Accounts_APAccountId",
column: x => x.APAccountId,
principalTable: "Accounts",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_VendorCredits_Vendors_VendorId",
column: x => x.VendorId,
principalTable: "Vendors",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "VendorCreditApplications",
columns: table => new
{
Id = table.Column<int>(type: "int", nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
VendorCreditId = table.Column<int>(type: "int", nullable: false),
BillId = table.Column<int>(type: "int", nullable: false),
Amount = table.Column<decimal>(type: "decimal(18,2)", nullable: false),
AppliedDate = table.Column<DateTime>(type: "datetime2", 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_VendorCreditApplications", x => x.Id);
table.ForeignKey(
name: "FK_VendorCreditApplications_Bills_BillId",
column: x => x.BillId,
principalTable: "Bills",
principalColumn: "Id",
onDelete: ReferentialAction.NoAction);
table.ForeignKey(
name: "FK_VendorCreditApplications_VendorCredits_VendorCreditId",
column: x => x.VendorCreditId,
principalTable: "VendorCredits",
principalColumn: "Id",
onDelete: ReferentialAction.NoAction);
});
migrationBuilder.CreateTable(
name: "VendorCreditLineItems",
columns: table => new
{
Id = table.Column<int>(type: "int", nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
VendorCreditId = table.Column<int>(type: "int", nullable: false),
AccountId = table.Column<int>(type: "int", nullable: true),
Description = table.Column<string>(type: "nvarchar(max)", nullable: false),
Amount = 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_VendorCreditLineItems", x => x.Id);
table.ForeignKey(
name: "FK_VendorCreditLineItems_Accounts_AccountId",
column: x => x.AccountId,
principalTable: "Accounts",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_VendorCreditLineItems_VendorCredits_VendorCreditId",
column: x => x.VendorCreditId,
principalTable: "VendorCredits",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 1,
column: "CreatedAt",
value: new DateTime(2026, 5, 10, 3, 58, 27, 360, DateTimeKind.Utc).AddTicks(6994));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 2,
column: "CreatedAt",
value: new DateTime(2026, 5, 10, 3, 58, 27, 360, DateTimeKind.Utc).AddTicks(7001));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 3,
column: "CreatedAt",
value: new DateTime(2026, 5, 10, 3, 58, 27, 360, DateTimeKind.Utc).AddTicks(7003));
migrationBuilder.CreateIndex(
name: "IX_VendorCreditApplications_BillId",
table: "VendorCreditApplications",
column: "BillId");
migrationBuilder.CreateIndex(
name: "IX_VendorCreditApplications_VendorCreditId",
table: "VendorCreditApplications",
column: "VendorCreditId");
migrationBuilder.CreateIndex(
name: "IX_VendorCreditLineItems_AccountId",
table: "VendorCreditLineItems",
column: "AccountId");
migrationBuilder.CreateIndex(
name: "IX_VendorCreditLineItems_VendorCreditId",
table: "VendorCreditLineItems",
column: "VendorCreditId");
migrationBuilder.CreateIndex(
name: "IX_VendorCredits_APAccountId",
table: "VendorCredits",
column: "APAccountId");
migrationBuilder.CreateIndex(
name: "IX_VendorCredits_VendorId",
table: "VendorCredits",
column: "VendorId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "VendorCreditApplications");
migrationBuilder.DropTable(
name: "VendorCreditLineItems");
migrationBuilder.DropTable(
name: "VendorCredits");
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 1,
column: "CreatedAt",
value: new DateTime(2026, 5, 10, 3, 45, 31, 524, DateTimeKind.Utc).AddTicks(9350));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 2,
column: "CreatedAt",
value: new DateTime(2026, 5, 10, 3, 45, 31, 524, DateTimeKind.Utc).AddTicks(9357));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 3,
column: "CreatedAt",
value: new DateTime(2026, 5, 10, 3, 45, 31, 524, DateTimeKind.Utc).AddTicks(9359));
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,166 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace PowderCoating.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class AddBankReconciliation : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<DateTime>(
name: "ClearedDate",
table: "Payments",
type: "datetime2",
nullable: true);
migrationBuilder.AddColumn<bool>(
name: "IsCleared",
table: "Payments",
type: "bit",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<DateTime>(
name: "ClearedDate",
table: "Expenses",
type: "datetime2",
nullable: true);
migrationBuilder.AddColumn<bool>(
name: "IsCleared",
table: "Expenses",
type: "bit",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<DateTime>(
name: "ClearedDate",
table: "BillPayments",
type: "datetime2",
nullable: true);
migrationBuilder.AddColumn<bool>(
name: "IsCleared",
table: "BillPayments",
type: "bit",
nullable: false,
defaultValue: false);
migrationBuilder.CreateTable(
name: "BankReconciliations",
columns: table => new
{
Id = table.Column<int>(type: "int", nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
AccountId = table.Column<int>(type: "int", nullable: false),
StatementDate = table.Column<DateTime>(type: "datetime2", nullable: false),
BeginningBalance = table.Column<decimal>(type: "decimal(18,2)", nullable: false),
EndingBalance = table.Column<decimal>(type: "decimal(18,2)", nullable: false),
Status = table.Column<int>(type: "int", nullable: false),
CompletedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
CompletedBy = table.Column<string>(type: "nvarchar(max)", nullable: true),
Notes = 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_BankReconciliations", x => x.Id);
table.ForeignKey(
name: "FK_BankReconciliations_Accounts_AccountId",
column: x => x.AccountId,
principalTable: "Accounts",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
});
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));
migrationBuilder.CreateIndex(
name: "IX_BankReconciliations_AccountId",
table: "BankReconciliations",
column: "AccountId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "BankReconciliations");
migrationBuilder.DropColumn(
name: "ClearedDate",
table: "Payments");
migrationBuilder.DropColumn(
name: "IsCleared",
table: "Payments");
migrationBuilder.DropColumn(
name: "ClearedDate",
table: "Expenses");
migrationBuilder.DropColumn(
name: "IsCleared",
table: "Expenses");
migrationBuilder.DropColumn(
name: "ClearedDate",
table: "BillPayments");
migrationBuilder.DropColumn(
name: "IsCleared",
table: "BillPayments");
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 1,
column: "CreatedAt",
value: new DateTime(2026, 5, 10, 3, 58, 27, 360, DateTimeKind.Utc).AddTicks(6994));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 2,
column: "CreatedAt",
value: new DateTime(2026, 5, 10, 3, 58, 27, 360, DateTimeKind.Utc).AddTicks(7001));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 3,
column: "CreatedAt",
value: new DateTime(2026, 5, 10, 3, 58, 27, 360, DateTimeKind.Utc).AddTicks(7003));
}
}
}
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
@@ -142,6 +142,29 @@ public class UnitOfWork : IUnitOfWork
private IRepository<BillPayment>? _billPayments; private IRepository<BillPayment>? _billPayments;
private IRepository<Expense>? _expenses; private IRepository<Expense>? _expenses;
// Manual Journal Entries
private IRepository<JournalEntry>? _journalEntries;
private IRepository<JournalEntryLine>? _journalEntryLines;
// Vendor Credits
private IRepository<VendorCredit>? _vendorCredits;
private IRepository<VendorCreditLineItem>? _vendorCreditLineItems;
private IRepository<VendorCreditApplication>? _vendorCreditApplications;
// 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> /// <summary>
/// Initialises the unit of work with the scoped <paramref name="context"/>. /// Initialises the unit of work with the scoped <paramref name="context"/>.
/// The context is shared across all repositories created by this instance so that /// The context is shared across all repositories created by this instance so that
@@ -513,6 +536,53 @@ public class UnitOfWork : IUnitOfWork
public IRepository<Expense> Expenses => public IRepository<Expense> Expenses =>
_expenses ??= new Repository<Expense>(_context); _expenses ??= new Repository<Expense>(_context);
// Manual Journal Entries
/// <summary>Repository for <see cref="JournalEntry"/> double-entry manual journal entries; tenant-filtered with soft delete.</summary>
public IRepository<JournalEntry> JournalEntries =>
_journalEntries ??= new Repository<JournalEntry>(_context);
/// <summary>Repository for <see cref="JournalEntryLine"/> individual debit/credit lines within a journal entry.</summary>
public IRepository<JournalEntryLine> JournalEntryLines =>
_journalEntryLines ??= new Repository<JournalEntryLine>(_context);
// Vendor Credits
/// <summary>Repository for <see cref="VendorCredit"/> credit notes received from vendors; tenant-filtered with soft delete.</summary>
public IRepository<VendorCredit> VendorCredits =>
_vendorCredits ??= new Repository<VendorCredit>(_context);
/// <summary>Repository for <see cref="VendorCreditLineItem"/> expense-reversal lines on a vendor credit.</summary>
public IRepository<VendorCreditLineItem> VendorCreditLineItems =>
_vendorCreditLineItems ??= new Repository<VendorCreditLineItem>(_context);
/// <summary>Repository for <see cref="VendorCreditApplication"/> records linking a vendor credit to a specific bill.</summary>
public IRepository<VendorCreditApplication> VendorCreditApplications =>
_vendorCreditApplications ??= new Repository<VendorCreditApplication>(_context);
// Bank Reconciliation
/// <summary>Repository for <see cref="BankReconciliation"/> sessions reconciling a bank account against a statement.</summary>
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> /// <summary>
/// Flushes all pending changes in the EF Core change tracker to the database. /// Flushes all pending changes in the EF Core change tracker to the database.
/// Returns the number of state entries written. /// Returns the number of state entries written.
@@ -46,7 +46,7 @@ public class AccountBalanceService : IAccountBalanceService
// Debit increases debit-normal accounts (Assets/Expenses/COGS) // Debit increases debit-normal accounts (Assets/Expenses/COGS)
// Debit decreases credit-normal accounts (Liabilities/Equity/Revenue) // Debit decreases credit-normal accounts (Liabilities/Equity/Revenue)
account.CurrentBalance += IsNormalDebitBalance(account.AccountSubType) ? amount : -amount; account.CurrentBalance += AccountingRules.IsNormalDebitBalance(account.AccountSubType) ? amount : -amount;
await _unitOfWork.Accounts.UpdateAsync(account); await _unitOfWork.Accounts.UpdateAsync(account);
} }
@@ -65,7 +65,7 @@ public class AccountBalanceService : IAccountBalanceService
// Credit decreases debit-normal accounts (Assets/Expenses/COGS) // Credit decreases debit-normal accounts (Assets/Expenses/COGS)
// Credit increases credit-normal accounts (Liabilities/Equity/Revenue) // Credit increases credit-normal accounts (Liabilities/Equity/Revenue)
account.CurrentBalance += IsNormalDebitBalance(account.AccountSubType) ? -amount : amount; account.CurrentBalance += AccountingRules.IsNormalDebitBalance(account.AccountSubType) ? -amount : amount;
await _unitOfWork.Accounts.UpdateAsync(account); await _unitOfWork.Accounts.UpdateAsync(account);
} }
@@ -109,28 +109,4 @@ public class AccountBalanceService : IAccountBalanceService
await _unitOfWork.CompleteAsync(); await _unitOfWork.CompleteAsync();
} }
/// <summary>
/// Returns <c>true</c> for account sub-types whose normal balance is a debit
/// (Assets, COGS, Expenses). This mirrors the identical helper in <see cref="LedgerService"/>
/// and is the single source of truth for how <see cref="DebitAsync"/> and <see cref="CreditAsync"/>
/// decide the direction of the balance adjustment.
/// </summary>
private static bool IsNormalDebitBalance(AccountSubType subType) => subType switch
{
AccountSubType.Cash
or AccountSubType.Checking
or AccountSubType.Savings
or AccountSubType.AccountsReceivable
or AccountSubType.Inventory
or AccountSubType.FixedAsset
or AccountSubType.OtherCurrentAsset
or AccountSubType.OtherAsset => true,
AccountSubType.CostOfGoodsSold => true,
// Expense subtypes (enum values ≥ 50) → normal debit balance
var st when (int)st >= 50 => true,
_ => false
};
} }
@@ -902,4 +902,454 @@ Account Spend Trends (this month vs historical):
return new AnomalyDetectionResult { Success = false, ErrorMessage = "An error occurred while running the analysis." }; 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." };
}
}
} }
@@ -0,0 +1,41 @@
using PowderCoating.Core.Enums;
namespace PowderCoating.Infrastructure.Services;
/// <summary>
/// Single source of truth for double-entry sign conventions shared by
/// <see cref="AccountBalanceService"/> and <see cref="LedgerService"/>.
/// Centralised here so that adding a new AccountSubType only requires
/// one edit rather than two independently maintained switch expressions.
/// </summary>
internal static class AccountingRules
{
/// <summary>
/// Returns <c>true</c> for sub-types whose normal balance is a debit
/// (Assets, COGS, Expenses). Sub-type is used rather than AccountType
/// because it is constrained to a known enum set and cannot be
/// misconfigured by a user. Expense enum values are ≥ 50 by convention,
/// allowing a catch-all range match for any future expense sub-types.
/// </summary>
internal static bool IsNormalDebitBalance(AccountSubType subType) => subType switch
{
// Asset subtypes → normal debit balance
AccountSubType.Cash
or AccountSubType.Checking
or AccountSubType.Savings
or AccountSubType.AccountsReceivable
or AccountSubType.Inventory
or AccountSubType.FixedAsset
or AccountSubType.OtherCurrentAsset
or AccountSubType.OtherAsset => true,
// COGS → normal debit balance
AccountSubType.CostOfGoodsSold => true,
// Expense subtypes (enum values ≥ 50) → normal debit balance
var st when (int)st >= 50 => true,
// Liability subtypes (AP, CreditCard, etc.), Equity, Revenue → normal credit balance
_ => false
};
}
@@ -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"; 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. 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} {shopSpeedLine}
{coatingSpeedLine} {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). 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. 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. 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 _ => 0
}; };
// Labor cost — AI returns total batch minutes, so divide by quantity to get per-item minutes. // Labor cost — AI returns per-item minutes (both system prompt and user prompt say "per single item").
// The unit price × quantity must equal the total batch labor cost. // Unit price is per item; the caller multiplies by quantity for the line total.
var rawPerItemMinutes = aiResult.EstimatedMinutes / Math.Max(1m, (decimal)request.Quantity); var rawPerItemMinutes = aiResult.EstimatedMinutes;
var minFloorApplied = materialMinMinutes > 0 && rawPerItemMinutes < materialMinMinutes; var minFloorApplied = materialMinMinutes > 0 && rawPerItemMinutes < materialMinMinutes;
var perItemMinutes = minFloorApplied ? materialMinMinutes : rawPerItemMinutes; var perItemMinutes = minFloorApplied ? materialMinMinutes : rawPerItemMinutes;
var laborHours = perItemMinutes / 60m; var laborHours = perItemMinutes / 60m;
@@ -611,7 +619,7 @@ Respond with the JSON object only.";
CoatCount = request.CoatCount, CoatCount = request.CoatCount,
MaterialCost = Math.Round(materialCost, 2), MaterialCost = Math.Round(materialCost, 2),
ConsumablesCost = Math.Round(consumablesSurcharge, 2), ConsumablesCost = Math.Round(consumablesSurcharge, 2),
EstimatedMinutes = (int)Math.Round(perItemMinutes), EstimatedMinutes = perItemMinutes,
MaterialMinMinutes = materialMinMinutes, MaterialMinMinutes = materialMinMinutes,
MinFloorApplied = minFloorApplied, MinFloorApplied = minFloorApplied,
LaborCost = Math.Round(laborCost, 2), LaborCost = Math.Round(laborCost, 2),
@@ -137,6 +137,16 @@ public class ApplicationUserClaimsPrincipalFactory : UserClaimsPrincipalFactory<
identity.AddClaim(new Claim("Permission", "ViewReports")); 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; return identity;
} }
} }
@@ -67,6 +67,10 @@ public class CompanyListService : ICompanyListService
/// <inheritdoc/> /// <inheritdoc/>
public async Task<CompanyCountSummary> GetCountSummaryAsync(IReadOnlyList<int> companyIds) 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 var jobCounts = await _context.Jobs
.IgnoreQueryFilters() .IgnoreQueryFilters()
.Where(j => companyIds.Contains(j.CompanyId) && !j.IsDeleted) .Where(j => companyIds.Contains(j.CompanyId) && !j.IsDeleted)
@@ -98,6 +102,32 @@ public class CompanyListService : ICompanyListService
x => x.CompanyId, x => x.CompanyId,
x => new CompanyWizardInfo(true, x.SetupWizardCompletedAt, x.SetupWizardCompletedByName)); 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);
} }
} }
File diff suppressed because it is too large Load Diff
@@ -72,6 +72,45 @@ public class LedgerService : ILedgerService
LinkId = p.InvoiceId 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) ──────────────── // ── 2. Direct expenses paid FROM this account (CREDIT) ────────────────
// e.g. Checking account used to pay an expense // e.g. Checking account used to pay an expense
var expensesPaidFrom = await _context.Expenses var expensesPaidFrom = await _context.Expenses
@@ -251,6 +290,46 @@ public class LedgerService : ILedgerService
LinkController = "Invoices", LinkController = "Invoices",
LinkId = p.InvoiceId 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 ──────────────────────────────────────────────── // ── 9. Accounts Payable ────────────────────────────────────────────────
@@ -296,8 +375,126 @@ public class LedgerService : ILedgerService
LinkController = "Bills", LinkController = "Bills",
LinkId = bp.BillId 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 ──────────────────
var jeLines = await _context.JournalEntryLines
.Include(l => l.JournalEntry)
.Where(l => l.AccountId == accountId
&& l.JournalEntry.Status == JournalEntryStatus.Posted
&& l.JournalEntry.EntryDate >= fromDate
&& l.JournalEntry.EntryDate <= toDate)
.ToListAsync();
foreach (var line in jeLines)
entries.Add(new LedgerEntryDto
{
Date = line.JournalEntry.EntryDate,
Reference = line.JournalEntry.EntryNumber,
Source = "Journal Entry",
Description = line.Description ?? line.JournalEntry.Description,
Debit = line.DebitAmount,
Credit = line.CreditAmount,
LinkController = "JournalEntries",
LinkId = line.JournalEntry.Id
});
// ── Sort and compute running balance ────────────────────────────────── // ── Sort and compute running balance ──────────────────────────────────
entries = entries entries = entries
.OrderBy(e => e.Date) .OrderBy(e => e.Date)
@@ -306,7 +503,7 @@ public class LedgerService : ILedgerService
// Derive normal-debit-balance flag from AccountSubType (more authoritative than AccountType, // Derive normal-debit-balance flag from AccountSubType (more authoritative than AccountType,
// since users could misconfigure AccountType while SubType is picked from a constrained list). // since users could misconfigure AccountType while SubType is picked from a constrained list).
bool normalDebitBalance = IsNormalDebitBalance(account.AccountSubType); bool normalDebitBalance = AccountingRules.IsNormalDebitBalance(account.AccountSubType);
// Compute the balance before the selected period // Compute the balance before the selected period
decimal priorBalance = await ComputePriorBalanceAsync(account, fromDate, to.Date, normalDebitBalance); decimal priorBalance = await ComputePriorBalanceAsync(account, fromDate, to.Date, normalDebitBalance);
@@ -338,36 +535,6 @@ public class LedgerService : ILedgerService
}; };
} }
/// <summary>
/// Returns <c>true</c> if the account sub-type has a normal debit balance (Assets, Expenses, COGS),
/// <c>false</c> for normal credit balance (Liabilities, Equity, Revenue).
/// <see cref="AccountSubType"/> is used rather than <see cref="PowderCoating.Core.Enums.AccountType"/>
/// because sub-type is constrained to a known set of values and cannot be misconfigured by a user,
/// whereas <c>AccountType</c> is a broader category that a user might set incorrectly.
/// Expense enum values are ≥ 50 by convention, allowing a catch-all range match.
/// </summary>
private static bool IsNormalDebitBalance(AccountSubType subType) => subType switch
{
// Asset subtypes → normal debit balance
AccountSubType.Cash
or AccountSubType.Checking
or AccountSubType.Savings
or AccountSubType.AccountsReceivable
or AccountSubType.Inventory
or AccountSubType.FixedAsset
or AccountSubType.OtherCurrentAsset
or AccountSubType.OtherAsset => true,
// COGS → normal debit balance
AccountSubType.CostOfGoodsSold => true,
// Expense subtypes (enum values ≥ 50) → normal debit balance
var st when (int)st >= 50 => true,
// Liability subtypes (AP, CreditCard, etc.), Equity, Revenue → normal credit balance
_ => false
};
/// <summary> /// <summary>
/// Computes the account balance on the day immediately before <paramref name="beforeDate"/> /// Computes the account balance on the day immediately before <paramref name="beforeDate"/>
/// by summing all activity prior to that date across every transaction source and adding /// by summing all activity prior to that date across every transaction source and adding
@@ -375,7 +542,7 @@ public class LedgerService : ILedgerService
/// date is on or before <paramref name="periodEnd"/> — a future-dated opening balance (e.g. /// date is on or before <paramref name="periodEnd"/> — a future-dated opening balance (e.g.
/// from a mid-year chart-of-accounts migration) should not pollute earlier period reports. /// from a mid-year chart-of-accounts migration) should not pollute earlier period reports.
/// A null <c>OpeningBalanceDate</c> means the balance predates all transactions and always applies. /// A null <c>OpeningBalanceDate</c> means the balance predates all transactions and always applies.
/// The sign convention follows <see cref="IsNormalDebitBalance"/>: debits increase debit-normal /// The sign convention follows <see cref="AccountingRules.IsNormalDebitBalance"/>: debits increase debit-normal
/// accounts and credits increase credit-normal accounts. /// accounts and credits increase credit-normal accounts.
/// </summary> /// </summary>
private async Task<decimal> ComputePriorBalanceAsync( private async Task<decimal> ComputePriorBalanceAsync(
@@ -390,6 +557,16 @@ public class LedgerService : ILedgerService
.Where(p => p.DepositAccountId == accountId && p.PaymentDate < beforeDate) .Where(p => p.DepositAccountId == accountId && p.PaymentDate < beforeDate)
.SumAsync(p => (decimal?)p.Amount) ?? 0; .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) // 2. Direct expenses paid FROM this account (CREDIT)
credits += await _context.Expenses credits += await _context.Expenses
.Where(e => e.PaymentAccountId == accountId && e.Date < beforeDate) .Where(e => e.PaymentAccountId == accountId && e.Date < beforeDate)
@@ -442,6 +619,14 @@ public class LedgerService : ILedgerService
credits += await _context.Payments credits += await _context.Payments
.Where(p => p.PaymentDate < beforeDate) .Where(p => p.PaymentDate < beforeDate)
.SumAsync(p => (decimal?)p.Amount) ?? 0; .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 // 9. Accounts Payable
@@ -457,8 +642,51 @@ public class LedgerService : ILedgerService
debits += await _context.BillPayments debits += await _context.BillPayments
.Where(bp => bp.Bill.APAccountId == accountId && bp.PaymentDate < beforeDate) .Where(bp => bp.Bill.APAccountId == accountId && bp.PaymentDate < beforeDate)
.SumAsync(bp => (decimal?)bp.Amount) ?? 0; .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)
debits += await _context.JournalEntryLines
.Where(l => l.AccountId == accountId
&& l.JournalEntry.Status == JournalEntryStatus.Posted
&& l.JournalEntry.EntryDate < beforeDate)
.SumAsync(l => (decimal?)l.DebitAmount) ?? 0;
credits += await _context.JournalEntryLines
.Where(l => l.AccountId == accountId
&& l.JournalEntry.Status == JournalEntryStatus.Posted
&& l.JournalEntry.EntryDate < beforeDate)
.SumAsync(l => (decimal?)l.CreditAmount) ?? 0;
decimal netActivity = normalDebitBalance ? debits - credits : credits - debits; decimal netActivity = normalDebitBalance ? debits - credits : credits - debits;
// Apply the opening balance if it was established on or before the end of the viewed period. // Apply the opening balance if it was established on or before the end of the viewed period.
@@ -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 = "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 = "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 }, 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 ──────────────────────────────────────────── // ── COST OF GOODS SOLD ────────────────────────────────────────────
new Account { AccountNumber = "5000", Name = "Cost of Goods Sold", AccountType = AccountType.CostOfGoods, AccountSubType = AccountSubType.CostOfGoodsSold, IsSystem = false, IsActive = true, Description = "Direct cost of services delivered", CompanyId = company.Id, CreatedAt = now }, new Account { AccountNumber = "5000", Name = "Cost of Goods Sold", AccountType = AccountType.CostOfGoods, AccountSubType = AccountSubType.CostOfGoodsSold, IsSystem = false, IsActive = true, Description = "Direct cost of services delivered", CompanyId = company.Id, CreatedAt = now },
@@ -96,4 +100,44 @@ public partial class SeedDataService
return accounts.Count; 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; 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.Message = $"Lookup tables initialized for {company.CompanyName}";
result.Details = details; result.Details = details;
} }
@@ -31,6 +31,7 @@ public static class AppConstants
{ {
public const string CompanyAdmin = "CompanyAdmin"; public const string CompanyAdmin = "CompanyAdmin";
public const string Manager = "Manager"; public const string Manager = "Manager";
public const string Accountant = "Accountant";
public const string Worker = "Worker"; public const string Worker = "Worker";
public const string Viewer = "Viewer"; public const string Viewer = "Viewer";
} }
@@ -58,6 +59,8 @@ public static class AppConstants
public const string CanManageMaintenance = "CanManageMaintenance"; public const string CanManageMaintenance = "CanManageMaintenance";
public const string CanManageInvoices = "CanManageInvoices"; public const string CanManageInvoices = "CanManageInvoices";
public const string CanViewReports = "CanViewReports"; public const string CanViewReports = "CanViewReports";
public const string CanManageBills = "CanManageBills";
public const string CanManageAccounting = "CanManageAccounting";
} }
public static class FileUpload public static class FileUpload
@@ -103,6 +106,10 @@ public static class AppConstants
public const string FinancialSummary = "FinancialSummary"; public const string FinancialSummary = "FinancialSummary";
public const string CashFlowForecast = "CashFlowForecast"; public const string CashFlowForecast = "CashFlowForecast";
public const string AnomalyDetection = "AnomalyDetection"; 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 public static class Legal
@@ -134,4 +141,42 @@ public static class AppConstants
public const int Layer3MinJobs = 150; // Minimum jobs with actual powder data before Layer 3 predictive features unlock public const int Layer3MinJobs = 150; // Minimum jobs with actual powder data before Layer 3 predictive features unlock
public const int Layer2MinJobs = 10; // Minimum for efficiency trending to be meaningful public const int Layer2MinJobs = 10; // Minimum for efficiency trending to be meaningful
} }
/// <summary>
/// String codes stored in the JobStatusLookup and QuoteStatusLookup tables.
/// Using constants here means a DB code rename only requires one code change,
/// not a grep-and-replace across every controller.
/// </summary>
public static class StatusCodes
{
public static class Job
{
public const string Pending = "PENDING";
public const string Quoted = "QUOTED";
public const string Approved = "APPROVED";
public const string InPreparation = "IN_PREPARATION";
public const string Sandblasting = "SANDBLASTING";
public const string MaskingTaping = "MASKING_TAPING";
public const string Cleaning = "CLEANING";
public const string InOven = "IN_OVEN";
public const string Coating = "COATING";
public const string Curing = "CURING";
public const string QualityCheck = "QUALITY_CHECK";
public const string Completed = "COMPLETED";
public const string ReadyForPickup = "READY_FOR_PICKUP";
public const string Delivered = "DELIVERED";
public const string OnHold = "ON_HOLD";
public const string Cancelled = "CANCELLED";
}
public static class Quote
{
public const string Draft = "DRAFT";
public const string Sent = "SENT";
public const string Approved = "APPROVED";
public const string Rejected = "REJECTED";
public const string Converted = "CONVERTED";
public const string Expired = "EXPIRED";
}
}
} }
@@ -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);
}
@@ -3,6 +3,7 @@ using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using OfficeOpenXml; using OfficeOpenXml;
using OfficeOpenXml.Style; using OfficeOpenXml.Style;
using PowderCoating.Core.Entities;
using PowderCoating.Infrastructure.Data; using PowderCoating.Infrastructure.Data;
using PowderCoating.Shared.Constants; using PowderCoating.Shared.Constants;
using System.Drawing; using System.Drawing;
@@ -207,6 +208,81 @@ public class AccountDataExportController : Controller
writer.Write(content); writer.Write(content);
} }
// ── Data fetchers (single query per entity, superset of includes for both XLSX + CSV) ─────
/// <summary>
/// Fetches all non-deleted customers for the company, including <c>PricingTier</c> (needed by
/// the CSV path; harmlessly unused by the XLSX path). Bypasses the global tenant filter via
/// <c>IgnoreQueryFilters</c> because <c>ITenantContext</c> may be null for expired accounts.
/// </summary>
private Task<List<Customer>> FetchCustomersAsync(int companyId) =>
_db.Customers.AsNoTracking().IgnoreQueryFilters()
.Include(c => c.PricingTier)
.Where(c => c.CompanyId == companyId && !c.IsDeleted)
.OrderBy(c => c.CompanyName).ToListAsync();
/// <summary>Fetches all non-deleted jobs with Customer, JobStatus, and JobPriority included.</summary>
private Task<List<Job>> FetchJobsAsync(int companyId) =>
_db.Jobs.AsNoTracking().IgnoreQueryFilters()
.Include(j => j.Customer).Include(j => j.JobStatus).Include(j => j.JobPriority)
.Where(j => j.CompanyId == companyId && !j.IsDeleted)
.OrderByDescending(j => j.CreatedAt).ToListAsync();
/// <summary>
/// Fetches all non-deleted quotes with Customer and QuoteStatus included.
/// The XLSX path only needs QuoteStatus; the CSV path also uses Customer — the superset here
/// avoids a second query when both formats include this sheet in the same request.
/// </summary>
private Task<List<Quote>> FetchQuotesAsync(int companyId) =>
_db.Quotes.AsNoTracking().IgnoreQueryFilters()
.Include(q => q.Customer).Include(q => q.QuoteStatus)
.Where(q => q.CompanyId == companyId && !q.IsDeleted)
.OrderByDescending(q => q.QuoteDate).ToListAsync();
/// <summary>Fetches all non-deleted invoices with Customer included.</summary>
private Task<List<Invoice>> FetchInvoicesAsync(int companyId) =>
_db.Invoices.AsNoTracking().IgnoreQueryFilters()
.Include(i => i.Customer)
.Where(i => i.CompanyId == companyId && !i.IsDeleted)
.OrderByDescending(i => i.InvoiceDate).ToListAsync();
/// <summary>
/// Fetches all non-deleted inventory items with PrimaryVendor and InventoryCategory included.
/// Only the CSV path uses these navigations; XLSX reads only scalar fields but the join is cheap.
/// </summary>
private Task<List<InventoryItem>> FetchInventoryAsync(int companyId) =>
_db.InventoryItems.AsNoTracking().IgnoreQueryFilters()
.Include(i => i.PrimaryVendor).Include(i => i.InventoryCategory)
.Where(i => i.CompanyId == companyId && !i.IsDeleted)
.OrderBy(i => i.Name).ToListAsync();
/// <summary>Fetches all non-deleted equipment records for the company.</summary>
private Task<List<Equipment>> FetchEquipmentAsync(int companyId) =>
_db.Equipment.AsNoTracking().IgnoreQueryFilters()
.Where(e => e.CompanyId == companyId && !e.IsDeleted)
.OrderBy(e => e.EquipmentName).ToListAsync();
/// <summary>Fetches all non-deleted vendors for the company.</summary>
private Task<List<Vendor>> FetchVendorsAsync(int companyId) =>
_db.Vendors.AsNoTracking().IgnoreQueryFilters()
.Where(s => s.CompanyId == companyId && !s.IsDeleted)
.OrderBy(s => s.CompanyName).ToListAsync();
/// <summary>Fetches all non-deleted shop workers for the company.</summary>
private Task<List<ShopWorker>> FetchShopWorkersAsync(int companyId) =>
_db.ShopWorkers.AsNoTracking().IgnoreQueryFilters()
.Where(w => w.CompanyId == companyId && !w.IsDeleted)
.OrderBy(w => w.Name).ToListAsync();
/// <summary>
/// Fetches all users for the company. <c>IsDeleted</c> is intentionally omitted because
/// Identity users use <c>IsActive = false</c> for soft-deletion, not the base-entity flag.
/// </summary>
private Task<List<ApplicationUser>> FetchUsersAsync(int companyId) =>
_db.Users.AsNoTracking().IgnoreQueryFilters()
.Where(u => u.CompanyId == companyId)
.OrderBy(u => u.LastName).ToListAsync();
// ── Sheet builders ─────────────────────────────────────────────────────── // ── Sheet builders ───────────────────────────────────────────────────────
/// <summary> /// <summary>
@@ -226,16 +302,9 @@ public class AccountDataExportController : Controller
ws.Cells[1, 1, 3, 1].Style.Font.Bold = true; ws.Cells[1, 1, 3, 1].Style.Font.Bold = true;
} }
/// <summary>
/// Adds a "Customers" worksheet with one row per non-deleted customer belonging to the
/// authenticated user's company. <c>IgnoreQueryFilters()</c> bypasses the global EF
/// multi-tenancy filter (which relies on <c>ITenantContext</c>) in favour of the explicit
/// <c>CompanyId == companyId</c> predicate, making the filter independent of middleware state.
/// </summary>
private async Task AddCustomersSheet(ExcelPackage pkg, int companyId, Color hdr) private async Task AddCustomersSheet(ExcelPackage pkg, int companyId, Color hdr)
{ {
var data = await _db.Customers.AsNoTracking().IgnoreQueryFilters() var data = await FetchCustomersAsync(companyId);
.Where(c => c.CompanyId == companyId && !c.IsDeleted).OrderBy(c => c.CompanyName).ToListAsync();
var ws = pkg.Workbook.Worksheets.Add("Customers"); var ws = pkg.Workbook.Worksheets.Add("Customers");
var headers = new[] { "ID", "Company Name", "First Name", "Last Name", "Email", "Phone", var headers = new[] { "ID", "Company Name", "First Name", "Last Name", "Email", "Phone",
"Commercial", "City", "State", "Active", "Credit Limit", "Current Balance", "Created At" }; "Commercial", "City", "State", "Active", "Credit Limit", "Current Balance", "Created At" };
@@ -256,18 +325,13 @@ public class AccountDataExportController : Controller
} }
/// <summary> /// <summary>
/// Adds a "Jobs" worksheet with one row per non-deleted job belonging to the company. /// Adds a "Jobs" worksheet. Job status and priority are lookup-table entities (not enums);
/// Job status and priority are lookup-table entities (not enums) stored in /// they are eagerly loaded by <see cref="FetchJobsAsync"/> so their <c>DisplayName</c> is
/// <c>JobStatusLookup</c> and a parallel priority table; they are eagerly loaded so their /// available without N+1 queries. Falls back to the raw FK integer on data anomalies.
/// <c>DisplayName</c> property is available without additional queries.
/// If a lookup navigation is null (data anomaly), the raw FK integer is written as a fallback.
/// </summary> /// </summary>
private async Task AddJobsSheet(ExcelPackage pkg, int companyId, Color hdr) private async Task AddJobsSheet(ExcelPackage pkg, int companyId, Color hdr)
{ {
var data = await _db.Jobs.AsNoTracking().IgnoreQueryFilters() var data = await FetchJobsAsync(companyId);
.Where(j => j.CompanyId == companyId && !j.IsDeleted)
.Include(j => j.Customer).Include(j => j.JobStatus).Include(j => j.JobPriority)
.OrderByDescending(j => j.CreatedAt).ToListAsync();
var ws = pkg.Workbook.Worksheets.Add("Jobs"); var ws = pkg.Workbook.Worksheets.Add("Jobs");
var headers = new[] { "ID", "Job Number", "Customer", "Status", "Priority", var headers = new[] { "ID", "Job Number", "Customer", "Status", "Priority",
"Description", "Due Date", "Final Price", "Created At" }; "Description", "Due Date", "Final Price", "Created At" };
@@ -289,16 +353,12 @@ public class AccountDataExportController : Controller
} }
/// <summary> /// <summary>
/// Adds a "Quotes" worksheet with one row per non-deleted quote belonging to the company. /// Adds a "Quotes" worksheet. Prospect-only quotes show <c>ProspectCompanyName</c>;
/// Prospect-only quotes (before they are linked to a customer record) show /// fully linked quotes fall back to <c>Customer #{id}</c> when the navigation is null.
/// <c>ProspectCompanyName</c>; fully linked quotes fall back to the customer FK integer when
/// the navigation cannot be resolved — ensuring no row has a blank identifier column.
/// </summary> /// </summary>
private async Task AddQuotesSheet(ExcelPackage pkg, int companyId, Color hdr) private async Task AddQuotesSheet(ExcelPackage pkg, int companyId, Color hdr)
{ {
var data = await _db.Quotes.AsNoTracking().IgnoreQueryFilters() var data = await FetchQuotesAsync(companyId);
.Where(q => q.CompanyId == companyId && !q.IsDeleted)
.Include(q => q.QuoteStatus).OrderByDescending(q => q.QuoteDate).ToListAsync();
var ws = pkg.Workbook.Worksheets.Add("Quotes"); var ws = pkg.Workbook.Worksheets.Add("Quotes");
var headers = new[] { "ID", "Quote Number", "Customer / Prospect", "Status", var headers = new[] { "ID", "Quote Number", "Customer / Prospect", "Status",
"Quote Date", "Expiration Date", "Subtotal", "Tax", "Total" }; "Quote Date", "Expiration Date", "Subtotal", "Tax", "Total" };
@@ -317,16 +377,12 @@ public class AccountDataExportController : Controller
} }
/// <summary> /// <summary>
/// Adds an "Invoices" worksheet with one row per non-deleted invoice belonging to the company. /// Adds an "Invoices" worksheet. <c>BalanceDue</c> is a computed property on the entity
/// <c>BalanceDue</c> is a computed property (<c>Total - AmountPaid</c>) reflecting partial /// (<c>Total - AmountPaid</c>) so no extra aggregation query is needed.
/// payment state without an additional aggregation query.
/// Eagerly loads <c>Customer</c> so the customer name is available for the display column.
/// </summary> /// </summary>
private async Task AddInvoicesSheet(ExcelPackage pkg, int companyId, Color hdr) private async Task AddInvoicesSheet(ExcelPackage pkg, int companyId, Color hdr)
{ {
var data = await _db.Invoices.AsNoTracking().IgnoreQueryFilters() var data = await FetchInvoicesAsync(companyId);
.Where(i => i.CompanyId == companyId && !i.IsDeleted)
.Include(i => i.Customer).OrderByDescending(i => i.InvoiceDate).ToListAsync();
var ws = pkg.Workbook.Worksheets.Add("Invoices"); var ws = pkg.Workbook.Worksheets.Add("Invoices");
var headers = new[] { "ID", "Invoice #", "Customer", "Status", "Invoice Date", var headers = new[] { "ID", "Invoice #", "Customer", "Status", "Invoice Date",
"Due Date", "Subtotal", "Tax", "Total", "Amount Paid", "Balance Due" }; "Due Date", "Subtotal", "Tax", "Total", "Amount Paid", "Balance Due" };
@@ -348,15 +404,9 @@ public class AccountDataExportController : Controller
AutoFit(ws, headers.Length); AutoFit(ws, headers.Length);
} }
/// <summary>
/// Adds an "Inventory" worksheet with one row per non-deleted inventory item for the company.
/// Items are ordered alphabetically so the exported list matches the order users typically
/// see in the application's inventory index view.
/// </summary>
private async Task AddInventorySheet(ExcelPackage pkg, int companyId, Color hdr) private async Task AddInventorySheet(ExcelPackage pkg, int companyId, Color hdr)
{ {
var data = await _db.InventoryItems.AsNoTracking().IgnoreQueryFilters() var data = await FetchInventoryAsync(companyId);
.Where(i => i.CompanyId == companyId && !i.IsDeleted).OrderBy(i => i.Name).ToListAsync();
var ws = pkg.Workbook.Worksheets.Add("Inventory"); var ws = pkg.Workbook.Worksheets.Add("Inventory");
var headers = new[] { "ID", "Name", "SKU", "Category", "Qty on Hand", var headers = new[] { "ID", "Name", "SKU", "Category", "Qty on Hand",
"Unit", "Unit Cost", "Reorder Point", "Manufacturer", "Color" }; "Unit", "Unit Cost", "Reorder Point", "Manufacturer", "Color" };
@@ -373,14 +423,9 @@ public class AccountDataExportController : Controller
AutoFit(ws, headers.Length); AutoFit(ws, headers.Length);
} }
/// <summary>
/// Adds an "Equipment" worksheet with one row per non-deleted equipment record for the company.
/// Equipment status is stored as an enum and serialised via <c>ToString()</c> for a readable label.
/// </summary>
private async Task AddEquipmentSheet(ExcelPackage pkg, int companyId, Color hdr) private async Task AddEquipmentSheet(ExcelPackage pkg, int companyId, Color hdr)
{ {
var data = await _db.Equipment.AsNoTracking().IgnoreQueryFilters() var data = await FetchEquipmentAsync(companyId);
.Where(e => e.CompanyId == companyId && !e.IsDeleted).OrderBy(e => e.EquipmentName).ToListAsync();
var ws = pkg.Workbook.Worksheets.Add("Equipment"); var ws = pkg.Workbook.Worksheets.Add("Equipment");
var headers = new[] { "ID", "Name", "Type", "Serial Number", "Model", var headers = new[] { "ID", "Name", "Type", "Serial Number", "Model",
"Status", "Purchase Date", "Purchase Price", "Next Maintenance" }; "Status", "Purchase Date", "Purchase Price", "Next Maintenance" };
@@ -398,13 +443,9 @@ public class AccountDataExportController : Controller
AutoFit(ws, headers.Length); AutoFit(ws, headers.Length);
} }
/// <summary>
/// Adds a "Vendors" worksheet with one row per non-deleted vendor for the company.
/// </summary>
private async Task AddVendorsSheet(ExcelPackage pkg, int companyId, Color hdr) private async Task AddVendorsSheet(ExcelPackage pkg, int companyId, Color hdr)
{ {
var data = await _db.Vendors.AsNoTracking().IgnoreQueryFilters() var data = await FetchVendorsAsync(companyId);
.Where(s => s.CompanyId == companyId && !s.IsDeleted).OrderBy(s => s.CompanyName).ToListAsync();
var ws = pkg.Workbook.Worksheets.Add("Vendors"); var ws = pkg.Workbook.Worksheets.Add("Vendors");
var headers = new[] { "ID", "Company Name", "Contact", "Email", "Phone", "City", "State", "Preferred", "Active" }; var headers = new[] { "ID", "Company Name", "Contact", "Email", "Phone", "City", "State", "Preferred", "Active" };
WriteHeader(ws, headers, hdr); WriteHeader(ws, headers, hdr);
@@ -421,13 +462,9 @@ public class AccountDataExportController : Controller
AutoFit(ws, headers.Length); AutoFit(ws, headers.Length);
} }
/// <summary>
/// Adds a "Shop Workers" worksheet with one row per non-deleted shop worker for the company.
/// </summary>
private async Task AddShopWorkersSheet(ExcelPackage pkg, int companyId, Color hdr) private async Task AddShopWorkersSheet(ExcelPackage pkg, int companyId, Color hdr)
{ {
var data = await _db.ShopWorkers.AsNoTracking().IgnoreQueryFilters() var data = await FetchShopWorkersAsync(companyId);
.Where(w => w.CompanyId == companyId && !w.IsDeleted).OrderBy(w => w.Name).ToListAsync();
var ws = pkg.Workbook.Worksheets.Add("Shop Workers"); var ws = pkg.Workbook.Worksheets.Add("Shop Workers");
var headers = new[] { "ID", "Name", "Role", "Phone", "Email", "Active", "Notes" }; var headers = new[] { "ID", "Name", "Role", "Phone", "Email", "Active", "Notes" };
WriteHeader(ws, headers, hdr); WriteHeader(ws, headers, hdr);
@@ -443,16 +480,12 @@ public class AccountDataExportController : Controller
} }
/// <summary> /// <summary>
/// Adds a "Users" worksheet with one row per user belonging to the company. /// Adds a "Users" worksheet. All users (active and inactive) are included because Identity
/// The <c>IsDeleted</c> predicate is intentionally omitted because ASP.NET Identity users /// uses <c>IsActive = false</c> for soft-deletion; <c>IsDeleted</c> is not applicable here.
/// use <c>IsActive = false</c> as their soft-deletion mechanism, not the base-entity
/// <c>IsDeleted</c> flag. All users (active and inactive) are included so the export
/// provides a complete workforce record for compliance and audit purposes.
/// </summary> /// </summary>
private async Task AddUsersSheet(ExcelPackage pkg, int companyId, Color hdr) private async Task AddUsersSheet(ExcelPackage pkg, int companyId, Color hdr)
{ {
var data = await _db.Users.AsNoTracking().IgnoreQueryFilters() var data = await FetchUsersAsync(companyId);
.Where(u => u.CompanyId == companyId).OrderBy(u => u.LastName).ToListAsync();
var ws = pkg.Workbook.Worksheets.Add("Users"); var ws = pkg.Workbook.Worksheets.Add("Users");
var headers = new[] { "ID", "First Name", "Last Name", "Email", "Role", "Active", "Hire Date", "Last Login", "Created At" }; var headers = new[] { "ID", "First Name", "Last Name", "Email", "Role", "Active", "Hire Date", "Last Login", "Created At" };
WriteHeader(ws, headers, hdr); WriteHeader(ws, headers, hdr);
@@ -472,15 +505,12 @@ public class AccountDataExportController : Controller
// ── CSV builders ───────────────────────────────────────────────────────── // ── CSV builders ─────────────────────────────────────────────────────────
/// <summary> /// <summary>
/// Builds the customers CSV string for the company. /// Column names match <c>CustomerImportDto</c> exactly so the file can be re-imported
/// Column names match <see cref="CustomerImportDto"/> exactly so the file can be re-imported
/// via Tools → Bulk Import without any manual header editing. /// via Tools → Bulk Import without any manual header editing.
/// </summary> /// </summary>
private async Task<string> BuildCustomersCsv(int companyId) private async Task<string> BuildCustomersCsv(int companyId)
{ {
var data = await _db.Customers.AsNoTracking().IgnoreQueryFilters() var data = await FetchCustomersAsync(companyId);
.Include(c => c.PricingTier)
.Where(c => c.CompanyId == companyId && !c.IsDeleted).OrderBy(c => c.CompanyName).ToListAsync();
var sb = new StringBuilder(); var sb = new StringBuilder();
sb.AppendLine("CompanyName,ContactFirstName,ContactLastName,Email,Phone,MobilePhone,Address,City,State,ZipCode,Country,CustomerType,PricingTierCode,CreditLimit,PaymentTerms,TaxExempt,TaxId,IsActive,Notes"); sb.AppendLine("CompanyName,ContactFirstName,ContactLastName,Email,Phone,MobilePhone,Address,City,State,ZipCode,Country,CustomerType,PricingTierCode,CreditLimit,PaymentTerms,TaxExempt,TaxId,IsActive,Notes");
foreach (var c in data) foreach (var c in data)
@@ -492,16 +522,12 @@ public class AccountDataExportController : Controller
} }
/// <summary> /// <summary>
/// Builds the jobs CSV string for the company. /// Column names match <c>JobImportDto</c> exactly so the file can be re-imported.
/// Column names match <see cref="JobImportDto"/> exactly so the file can be re-imported. /// CustomerEmail is used (not display name) because the importer resolves the customer FK by email.
/// CustomerEmail is included (not the display name) because the importer resolves the customer FK by email.
/// </summary> /// </summary>
private async Task<string> BuildJobsCsv(int companyId) private async Task<string> BuildJobsCsv(int companyId)
{ {
var data = await _db.Jobs.AsNoTracking().IgnoreQueryFilters() var data = await FetchJobsAsync(companyId);
.Where(j => j.CompanyId == companyId && !j.IsDeleted)
.Include(j => j.Customer).Include(j => j.JobStatus).Include(j => j.JobPriority)
.OrderByDescending(j => j.CreatedAt).ToListAsync();
var sb = new StringBuilder(); var sb = new StringBuilder();
sb.AppendLine("JobNumber,CustomerEmail,CustomerName,Status,Priority,ScheduledDate,DueDate,FinalPrice,CustomerPO,SpecialInstructions,Notes"); sb.AppendLine("JobNumber,CustomerEmail,CustomerName,Status,Priority,ScheduledDate,DueDate,FinalPrice,CustomerPO,SpecialInstructions,Notes");
foreach (var j in data) foreach (var j in data)
@@ -514,15 +540,10 @@ public class AccountDataExportController : Controller
return sb.ToString(); return sb.ToString();
} }
/// <summary> /// <summary>Column names match <c>QuoteImportDto</c> exactly so the file can be re-imported.</summary>
/// Builds the quotes CSV string for the company.
/// Column names match <see cref="QuoteImportDto"/> exactly so the file can be re-imported.
/// </summary>
private async Task<string> BuildQuotesCsv(int companyId) private async Task<string> BuildQuotesCsv(int companyId)
{ {
var data = await _db.Quotes.AsNoTracking().IgnoreQueryFilters() var data = await FetchQuotesAsync(companyId);
.Where(q => q.CompanyId == companyId && !q.IsDeleted)
.Include(q => q.Customer).Include(q => q.QuoteStatus).OrderByDescending(q => q.QuoteDate).ToListAsync();
var sb = new StringBuilder(); var sb = new StringBuilder();
sb.AppendLine("QuoteNumber,CustomerEmail,CustomerName,ProspectCompany,ProspectContact,ProspectEmail,ProspectPhone,Status,QuoteDate,ExpirationDate,Subtotal,TaxAmount,Total,Notes,TermsAndConditions"); sb.AppendLine("QuoteNumber,CustomerEmail,CustomerName,ProspectCompany,ProspectContact,ProspectEmail,ProspectPhone,Status,QuoteDate,ExpirationDate,Subtotal,TaxAmount,Total,Notes,TermsAndConditions");
foreach (var q in data) foreach (var q in data)
@@ -536,15 +557,12 @@ public class AccountDataExportController : Controller
} }
/// <summary> /// <summary>
/// Builds the invoices CSV string for the company, ordered newest-first.
/// Customer name resolution mirrors the XLSX sheet: company name preferred, with /// Customer name resolution mirrors the XLSX sheet: company name preferred, with
/// first+last name concatenation as the fallback for non-commercial customers. /// first+last name concatenation as the fallback for non-commercial customers.
/// </summary> /// </summary>
private async Task<string> BuildInvoicesCsv(int companyId) private async Task<string> BuildInvoicesCsv(int companyId)
{ {
var data = await _db.Invoices.AsNoTracking().IgnoreQueryFilters() var data = await FetchInvoicesAsync(companyId);
.Where(i => i.CompanyId == companyId && !i.IsDeleted)
.Include(i => i.Customer).OrderByDescending(i => i.InvoiceDate).ToListAsync();
var sb = new StringBuilder(); var sb = new StringBuilder();
sb.AppendLine("ID,Invoice #,Customer,Status,Invoice Date,Due Date,Subtotal,Tax,Total,Amount Paid,Balance Due"); sb.AppendLine("ID,Invoice #,Customer,Status,Invoice Date,Due Date,Subtotal,Tax,Total,Amount Paid,Balance Due");
foreach (var inv in data) foreach (var inv in data)
@@ -557,16 +575,10 @@ public class AccountDataExportController : Controller
return sb.ToString(); return sb.ToString();
} }
/// <summary> /// <summary>Column names match <c>InventoryItemImportDto</c> exactly so the file can be re-imported.</summary>
/// Builds the inventory CSV string for the company.
/// Column names match <see cref="InventoryItemImportDto"/> exactly so the file can be re-imported.
/// </summary>
private async Task<string> BuildInventoryCsv(int companyId) private async Task<string> BuildInventoryCsv(int companyId)
{ {
var data = await _db.InventoryItems.AsNoTracking().IgnoreQueryFilters() var data = await FetchInventoryAsync(companyId);
.Include(i => i.PrimaryVendor)
.Include(i => i.InventoryCategory)
.Where(i => i.CompanyId == companyId && !i.IsDeleted).OrderBy(i => i.Name).ToListAsync();
var sb = new StringBuilder(); var sb = new StringBuilder();
sb.AppendLine("SKU,ItemName,Description,CategoryName,Manufacturer,ManufacturerPartNumber,ColorName,ColorCode,Finish,VendorName,VendorPartNumber,QuantityInStock,UnitOfMeasure,UnitCost,LastPurchasePrice,ReorderPoint,ReorderQuantity,MinimumStock,MaximumStock,CoverageSqFtPerLb,TransferEfficiencyPct,Location,IsActive,Notes"); sb.AppendLine("SKU,ItemName,Description,CategoryName,Manufacturer,ManufacturerPartNumber,ColorName,ColorCode,Finish,VendorName,VendorPartNumber,QuantityInStock,UnitOfMeasure,UnitCost,LastPurchasePrice,ReorderPoint,ReorderQuantity,MinimumStock,MaximumStock,CoverageSqFtPerLb,TransferEfficiencyPct,Location,IsActive,Notes");
foreach (var i in data) foreach (var i in data)
@@ -577,14 +589,10 @@ public class AccountDataExportController : Controller
return sb.ToString(); return sb.ToString();
} }
/// <summary> /// <summary>Column names match <c>EquipmentImportDto</c> exactly so the file can be re-imported.</summary>
/// Builds the equipment CSV string for the company.
/// Column names match <see cref="EquipmentImportDto"/> exactly so the file can be re-imported.
/// </summary>
private async Task<string> BuildEquipmentCsv(int companyId) private async Task<string> BuildEquipmentCsv(int companyId)
{ {
var data = await _db.Equipment.AsNoTracking().IgnoreQueryFilters() var data = await FetchEquipmentAsync(companyId);
.Where(e => e.CompanyId == companyId && !e.IsDeleted).OrderBy(e => e.EquipmentName).ToListAsync();
var sb = new StringBuilder(); var sb = new StringBuilder();
sb.AppendLine("EquipmentName,EquipmentNumber,EquipmentType,Manufacturer,Model,SerialNumber,PurchaseDate,PurchasePrice,WarrantyExpiration,Location,RecommendedMaintenanceIntervalDays,Status,IsActive,Notes"); sb.AppendLine("EquipmentName,EquipmentNumber,EquipmentType,Manufacturer,Model,SerialNumber,PurchaseDate,PurchasePrice,WarrantyExpiration,Location,RecommendedMaintenanceIntervalDays,Status,IsActive,Notes");
foreach (var e in data) foreach (var e in data)
@@ -592,14 +600,10 @@ public class AccountDataExportController : Controller
return sb.ToString(); return sb.ToString();
} }
/// <summary> /// <summary>Column names match <c>VendorImportDto</c> exactly so the file can be re-imported.</summary>
/// Builds the vendors CSV string for the company.
/// Column names match <see cref="VendorImportDto"/> exactly so the file can be re-imported.
/// </summary>
private async Task<string> BuildVendorsCsv(int companyId) private async Task<string> BuildVendorsCsv(int companyId)
{ {
var data = await _db.Vendors.AsNoTracking().IgnoreQueryFilters() var data = await FetchVendorsAsync(companyId);
.Where(s => s.CompanyId == companyId && !s.IsDeleted).OrderBy(s => s.CompanyName).ToListAsync();
var sb = new StringBuilder(); var sb = new StringBuilder();
sb.AppendLine("CompanyName,ContactName,Email,Phone,Address,City,State,ZipCode,Country,Website,AccountNumber,TaxId,PaymentTerms,CreditLimit,IsPreferred,IsActive,Notes"); sb.AppendLine("CompanyName,ContactName,Email,Phone,Address,City,State,ZipCode,Country,Website,AccountNumber,TaxId,PaymentTerms,CreditLimit,IsPreferred,IsActive,Notes");
foreach (var s in data) foreach (var s in data)
@@ -607,14 +611,10 @@ public class AccountDataExportController : Controller
return sb.ToString(); return sb.ToString();
} }
/// <summary> /// <summary>Column names match <c>ShopWorkerImportDto</c> exactly so the file can be re-imported.</summary>
/// Builds the shop workers CSV string for the company.
/// Column names match <see cref="ShopWorkerImportDto"/> exactly so the file can be re-imported.
/// </summary>
private async Task<string> BuildShopWorkersCsv(int companyId) private async Task<string> BuildShopWorkersCsv(int companyId)
{ {
var data = await _db.ShopWorkers.AsNoTracking().IgnoreQueryFilters() var data = await FetchShopWorkersAsync(companyId);
.Where(w => w.CompanyId == companyId && !w.IsDeleted).OrderBy(w => w.Name).ToListAsync();
var sb = new StringBuilder(); var sb = new StringBuilder();
sb.AppendLine("Name,Role,Phone,Email,IsActive,Notes"); sb.AppendLine("Name,Role,Phone,Email,IsActive,Notes");
foreach (var w in data) foreach (var w in data)
@@ -623,15 +623,12 @@ public class AccountDataExportController : Controller
} }
/// <summary> /// <summary>
/// Builds the users CSV string for the company. /// All users (active and inactive) are exported for completeness and compliance — mirrors
/// Like <see cref="AddUsersSheet"/>, the <c>IsDeleted</c> predicate is omitted because /// the reasoning in <see cref="AddUsersSheet"/> and <see cref="FetchUsersAsync"/>.
/// Identity users use <c>IsActive</c> for soft-deletion; all users are exported for
/// completeness and compliance.
/// </summary> /// </summary>
private async Task<string> BuildUsersCsv(int companyId) private async Task<string> BuildUsersCsv(int companyId)
{ {
var data = await _db.Users.AsNoTracking().IgnoreQueryFilters() var data = await FetchUsersAsync(companyId);
.Where(u => u.CompanyId == companyId).OrderBy(u => u.LastName).ToListAsync();
var sb = new StringBuilder(); var sb = new StringBuilder();
sb.AppendLine("ID,First Name,Last Name,Email,Role,Active,Hire Date,Last Login,Created At"); sb.AppendLine("ID,First Name,Last Name,Email,Role,Active,Hire Date,Last Login,Created At");
foreach (var u in data) foreach (var u in data)
@@ -427,6 +427,186 @@ public class AccountsController : Controller
return View(ledger); 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 ────────────────────────────────────────────────────────────── // ── Helpers ──────────────────────────────────────────────────────────────
/// <summary> /// <summary>
@@ -1,4 +1,4 @@
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting; using Microsoft.AspNetCore.RateLimiting;
@@ -102,7 +102,7 @@ public class AiQuickQuoteController : Controller
var walkIn = await GetOrCreateWalkInCustomerAsync(companyId); var walkIn = await GetOrCreateWalkInCustomerAsync(companyId);
// Draft status — nullable FK, gracefully absent if lookup not seeded // Draft status — nullable FK, gracefully absent if lookup not seeded
var draftStatus = await _unitOfWork.QuoteStatusLookups.FirstOrDefaultAsync(s => s.StatusCode == "DRAFT"); var draftStatus = await _unitOfWork.QuoteStatusLookups.FirstOrDefaultAsync(s => s.StatusCode == AppConstants.StatusCodes.Quote.Draft);
var quoteNumber = await GenerateQuoteNumberAsync(companyId); var quoteNumber = await GenerateQuoteNumberAsync(companyId);
var now = DateTime.UtcNow; var now = DateTime.UtcNow;
@@ -1,4 +1,4 @@
using AutoMapper; using AutoMapper;
using PowderCoating.Shared.Constants; using PowderCoating.Shared.Constants;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Identity;
@@ -117,14 +117,7 @@ public class AppointmentsController : Controller
// Map to DTOs // Map to DTOs
var appointmentDtos = _mapper.Map<List<AppointmentListDto>>(items); var appointmentDtos = _mapper.Map<List<AppointmentListDto>>(items);
// Create paged result var pagedResult = PagedResult<AppointmentListDto>.From(gridRequest, appointmentDtos, totalCount);
var pagedResult = new PagedResult<AppointmentListDto>
{
Items = appointmentDtos,
PageNumber = gridRequest.PageNumber,
PageSize = gridRequest.PageSize,
TotalCount = totalCount
};
// Set ViewBag // Set ViewBag
ViewBag.SearchTerm = searchTerm; ViewBag.SearchTerm = searchTerm;
@@ -550,7 +543,7 @@ public class AppointmentsController : Controller
j => j.Customer, j => j.Customer,
j => j.JobStatus); j => j.JobStatus);
var terminalCodes = new[] { "COMPLETED", "DELIVERED", "CANCELLED" }; var terminalCodes = new[] { AppConstants.StatusCodes.Job.Completed, AppConstants.StatusCodes.Job.Delivered, AppConstants.StatusCodes.Job.Cancelled };
var jobsInRange = allJobs.Where(j => var jobsInRange = allJobs.Where(j =>
!terminalCodes.Contains(j.JobStatus.StatusCode) && !terminalCodes.Contains(j.JobStatus.StatusCode) &&
((j.ScheduledDate.HasValue && j.ScheduledDate.Value.Date >= start.Date && j.ScheduledDate.Value.Date <= end.Date) || ((j.ScheduledDate.HasValue && j.ScheduledDate.Value.Date >= start.Date && j.ScheduledDate.Value.Date <= end.Date) ||
@@ -625,16 +618,16 @@ public class AppointmentsController : Controller
return statusCode switch return statusCode switch
{ {
"PENDING" or "QUOTED" => "#6c757d", // Gray AppConstants.StatusCodes.Job.Pending or "QUOTED" => "#6c757d", // Gray
"APPROVED" => "#0dcaf0", // Cyan "APPROVED" => "#0dcaf0", // Cyan
"IN_PREPARATION" or "SANDBLASTING" or AppConstants.StatusCodes.Job.InPreparation or AppConstants.StatusCodes.Job.Sandblasting or
"MASKING_TAPING" or "CLEANING" => "#0d6efd", // Blue AppConstants.StatusCodes.Job.MaskingTaping or AppConstants.StatusCodes.Job.Cleaning => "#0d6efd", // Blue
"IN_OVEN" or "CURING" => "#fd7e14", // Orange AppConstants.StatusCodes.Job.InOven or AppConstants.StatusCodes.Job.Curing => "#fd7e14", // Orange
"COATING" => "#6610f2", // Indigo AppConstants.StatusCodes.Job.Coating => "#6610f2", // Indigo
"QUALITY_CHECK" => "#20c997", // Teal AppConstants.StatusCodes.Job.QualityCheck => "#20c997", // Teal
"COMPLETED" or "DELIVERED" or "READY_FOR_PICKUP" => "#198754", // Green AppConstants.StatusCodes.Job.Completed or AppConstants.StatusCodes.Job.Delivered or AppConstants.StatusCodes.Job.ReadyForPickup => "#198754", // Green
"ON_HOLD" => "#ffc107", // Yellow AppConstants.StatusCodes.Job.OnHold => "#ffc107", // Yellow
"CANCELLED" => "#adb5bd", // Light gray AppConstants.StatusCodes.Job.Cancelled => "#adb5bd", // Light gray
_ => "#0d6efd" _ => "#0d6efd"
}; };
} }
@@ -752,7 +745,7 @@ public class AppointmentsController : Controller
{ {
try try
{ {
var terminalCodes = new[] { "COMPLETED", "DELIVERED", "CANCELLED" }; var terminalCodes = new[] { AppConstants.StatusCodes.Job.Completed, AppConstants.StatusCodes.Job.Delivered, AppConstants.StatusCodes.Job.Cancelled };
var allJobs = await _unitOfWork.Jobs.GetAllAsync(false, var allJobs = await _unitOfWork.Jobs.GetAllAsync(false,
j => j.Customer, j => j.JobStatus, j => j.JobItems); j => j.Customer, j => j.JobStatus, j => j.JobItems);
@@ -876,27 +869,18 @@ public class AppointmentsController : Controller
/// </summary> /// </summary>
private async Task<string> GenerateAppointmentNumberAsync() private async Task<string> GenerateAppointmentNumberAsync()
{ {
var now = DateTime.UtcNow; var prefix = $"APT-{DateTime.UtcNow:yyMM}-";
var prefix = $"APT-{now:yyMM}-"; var last = (await _unitOfWork.Appointments.FindAsync(
a => a.AppointmentNumber.StartsWith(prefix), ignoreQueryFilters: true))
// Get all appointments for current month (including soft-deleted)
var allAppointments = await _unitOfWork.Appointments.GetAllAsync(ignoreQueryFilters: true);
var monthAppointments = allAppointments
.Where(a => a.AppointmentNumber.StartsWith(prefix))
.OrderByDescending(a => a.AppointmentNumber) .OrderByDescending(a => a.AppointmentNumber)
.ToList(); .Select(a => a.AppointmentNumber)
.FirstOrDefault();
var lastNumber = 0; int next = 1;
if (monthAppointments.Any()) if (last != null && int.TryParse(last[prefix.Length..], out int num))
{ next = num + 1;
var lastAppointmentNumber = monthAppointments.First().AppointmentNumber;
var numberPart = lastAppointmentNumber.Split('-').Last();
int.TryParse(numberPart, out lastNumber);
}
var newNumber = lastNumber + 1; return $"{prefix}{next:D4}";
return $"{prefix}{newNumber:D4}";
} }
/// <summary> /// <summary>
@@ -0,0 +1,395 @@
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;
using PowderCoating.Core.Interfaces;
using PowderCoating.Shared.Constants;
namespace PowderCoating.Web.Controllers;
[Authorize(Policy = AppConstants.Policies.CanViewData)]
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,
IAccountingAiService accountingAi,
IAiUsageLogger usageLogger)
{
_unitOfWork = unitOfWork;
_tenantContext = tenantContext;
_accountingAi = accountingAi;
_usageLogger = usageLogger;
}
private bool AllowAccounting() =>
User.IsInRole("SuperAdmin") || User.IsInRole("Administrator") || User.IsInRole("Manager");
// ── Index ────────────────────────────────────────────────────────────────
/// <summary>Lists all reconciliation sessions for the company, newest first.</summary>
public async Task<IActionResult> Index()
{
if (!AllowAccounting()) return RedirectToAction("Landing", "Reports");
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var all = (await _unitOfWork.BankReconciliations.FindAsync(
br => br.CompanyId == companyId,
false,
br => br.Account))
.OrderByDescending(br => br.StatementDate)
.ThenByDescending(br => br.Id)
.ToList();
return View(all);
}
// ── Create ───────────────────────────────────────────────────────────────
[Authorize(Policy = AppConstants.Policies.CanManageAccounting)]
public async Task<IActionResult> Create()
{
if (!AllowAccounting()) return RedirectToAction("Landing", "Reports");
await PopulateAccountDropdownAsync();
return View(new BankReconciliation { StatementDate = DateTime.Today });
}
[HttpPost]
[Authorize(Policy = AppConstants.Policies.CanManageAccounting)]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Create(BankReconciliation model)
{
if (!AllowAccounting()) return RedirectToAction("Landing", "Reports");
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
// Set beginning balance from last completed reconciliation for this account, or 0
var lastCompleted = (await _unitOfWork.BankReconciliations.FindAsync(
br => br.CompanyId == companyId
&& br.AccountId == model.AccountId
&& br.Status == BankReconciliationStatus.Completed))
.OrderByDescending(br => br.StatementDate)
.FirstOrDefault();
model.BeginningBalance = lastCompleted?.EndingBalance ?? 0;
model.Status = BankReconciliationStatus.InProgress;
await _unitOfWork.BankReconciliations.AddAsync(model);
await _unitOfWork.CompleteAsync();
TempData["Success"] = "Reconciliation started.";
return RedirectToAction(nameof(Reconcile), new { id = model.Id });
}
// ── Reconcile (Working View) ──────────────────────────────────────────────
/// <summary>
/// Main working view. Shows all uncleared transactions for the account up to StatementDate
/// in two sections (deposits/credits and payments/debits) with checkboxes.
/// Running cleared balance and difference update via JS as the user checks items.
/// </summary>
public async Task<IActionResult> Reconcile(int id)
{
if (!AllowAccounting()) return RedirectToAction("Landing", "Reports");
var recon = (await _unitOfWork.BankReconciliations.FindAsync(
br => br.Id == id,
false,
br => br.Account))
.FirstOrDefault();
if (recon == null) return NotFound();
if (recon.Status == BankReconciliationStatus.Completed)
return RedirectToAction(nameof(Report), new { id });
var accountId = recon.AccountId;
var statementDate = recon.StatementDate;
// Customer payments deposited to this account
var deposits = (await _unitOfWork.Payments.FindAsync(
p => p.DepositAccountId == accountId && p.PaymentDate <= statementDate))
.Select(p => new ReconciliationItem
{
EntityType = "Payment",
EntityId = p.Id,
Date = p.PaymentDate,
Reference = p.Reference ?? $"PMT-{p.Id}",
Description = $"Payment #{p.InvoiceId}",
Amount = p.Amount,
IsCleared = p.IsCleared
}).ToList();
// Bill payments out of this account (debits — shown as negative in deposits)
var billPayments = (await _unitOfWork.BillPayments.FindAsync(
bp => bp.BankAccountId == accountId && bp.PaymentDate <= statementDate))
.Select(bp => new ReconciliationItem
{
EntityType = "BillPayment",
EntityId = bp.Id,
Date = bp.PaymentDate,
Reference = bp.PaymentNumber,
Description = bp.Memo ?? bp.BillId.ToString(),
Amount = bp.Amount,
IsCleared = bp.IsCleared
}).ToList();
// Direct expenses out of this account
var expenses = (await _unitOfWork.Expenses.FindAsync(
e => e.PaymentAccountId == accountId && e.Date <= statementDate))
.Select(e => new ReconciliationItem
{
EntityType = "Expense",
EntityId = e.Id,
Date = e.Date,
Reference = e.ExpenseNumber,
Description = e.Memo ?? string.Empty,
Amount = e.Amount,
IsCleared = e.IsCleared
}).ToList();
ViewBag.Recon = recon;
ViewBag.Deposits = deposits;
ViewBag.Payments = billPayments.Concat(expenses).OrderBy(p => p.Date).ToList();
return View();
}
// ── ToggleCleared (AJAX) ─────────────────────────────────────────────────
/// <summary>
/// AJAX endpoint. Marks a Payment, BillPayment, or Expense as cleared/uncleared.
/// Returns updated running totals as JSON.
/// </summary>
[HttpPost]
[Authorize(Policy = AppConstants.Policies.CanManageAccounting)]
[ValidateAntiForgeryToken]
public async Task<IActionResult> ToggleCleared(
int reconId, string entityType, int entityId, bool isCleared)
{
if (!AllowAccounting()) return Forbid();
var recon = await _unitOfWork.BankReconciliations.GetByIdAsync(reconId);
if (recon == null) return NotFound();
var now = isCleared ? DateTime.UtcNow : (DateTime?)null;
switch (entityType)
{
case "Payment":
var payment = await _unitOfWork.Payments.GetByIdAsync(entityId);
if (payment != null) { payment.IsCleared = isCleared; payment.ClearedDate = now; }
break;
case "BillPayment":
var bp = await _unitOfWork.BillPayments.GetByIdAsync(entityId);
if (bp != null) { bp.IsCleared = isCleared; bp.ClearedDate = now; }
break;
case "Expense":
var exp = await _unitOfWork.Expenses.GetByIdAsync(entityId);
if (exp != null) { exp.IsCleared = isCleared; exp.ClearedDate = now; }
break;
}
await _unitOfWork.CompleteAsync();
return Ok(new { success = true });
}
// ── Complete ─────────────────────────────────────────────────────────────
/// <summary>Completes the reconciliation. Only allowed when Difference == 0.00.</summary>
[HttpPost]
[Authorize(Policy = AppConstants.Policies.CanManageAccounting)]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Complete(int id, decimal difference)
{
if (!AllowAccounting()) return RedirectToAction("Landing", "Reports");
if (Math.Abs(difference) > 0.005m)
{
TempData["Error"] = $"Cannot complete: difference is {difference:C}. Must be $0.00.";
return RedirectToAction(nameof(Reconcile), new { id });
}
var recon = await _unitOfWork.BankReconciliations.GetByIdAsync(id);
if (recon == null) return NotFound();
recon.Status = BankReconciliationStatus.Completed;
recon.CompletedAt = DateTime.UtcNow;
recon.CompletedBy = User.Identity?.Name;
await _unitOfWork.CompleteAsync();
TempData["Success"] = "Reconciliation completed.";
return RedirectToAction(nameof(Report), new { id });
}
// ── Report ────────────────────────────────────────────────────────────────
/// <summary>Printable view of a completed reconciliation.</summary>
public async Task<IActionResult> Report(int id)
{
if (!AllowAccounting()) return RedirectToAction("Landing", "Reports");
var recon = (await _unitOfWork.BankReconciliations.FindAsync(
br => br.Id == id,
false,
br => br.Account))
.FirstOrDefault();
if (recon == null) return NotFound();
var accountId = recon.AccountId;
var clearedDeposits = (await _unitOfWork.Payments.FindAsync(
p => p.DepositAccountId == accountId && p.IsCleared && p.PaymentDate <= recon.StatementDate))
.ToList();
var clearedPayments = new List<ReconciliationItem>();
(await _unitOfWork.BillPayments.FindAsync(
bp => bp.BankAccountId == accountId && bp.IsCleared && bp.PaymentDate <= recon.StatementDate))
.ToList()
.ForEach(bp => clearedPayments.Add(new ReconciliationItem
{
EntityType = "BillPayment", EntityId = bp.Id, Date = bp.PaymentDate,
Reference = bp.PaymentNumber, Amount = bp.Amount, IsCleared = true
}));
(await _unitOfWork.Expenses.FindAsync(
e => e.PaymentAccountId == accountId && e.IsCleared && e.Date <= recon.StatementDate))
.ToList()
.ForEach(e => clearedPayments.Add(new ReconciliationItem
{
EntityType = "Expense", EntityId = e.Id, Date = e.Date,
Reference = e.ExpenseNumber, Amount = e.Amount, IsCleared = true
}));
ViewBag.ClearedDeposits = clearedDeposits;
ViewBag.ClearedPayments = clearedPayments.OrderBy(p => p.Date).ToList();
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()
{
var accounts = await _unitOfWork.Accounts.FindAsync(
a => a.IsActive
&& (a.AccountSubType == AccountSubType.Checking
|| a.AccountSubType == AccountSubType.Savings
|| a.AccountSubType == AccountSubType.Cash));
ViewBag.AccountSelectList = accounts
.OrderBy(a => a.AccountNumber)
.Select(a => new SelectListItem
{
Value = a.Id.ToString(),
Text = $"{a.AccountNumber} {a.Name}"
})
.ToList();
}
}
/// <summary>View model for a single reconcileable transaction row.</summary>
public class ReconciliationItem
{
public string EntityType { get; set; } = string.Empty;
public int EntityId { get; set; }
public DateTime Date { get; set; }
public string Reference { get; set; } = string.Empty;
public string Description { get; set; } = string.Empty;
public decimal Amount { get; set; }
public bool IsCleared { get; set; }
}
@@ -1,4 +1,4 @@
using AutoMapper; using AutoMapper;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using PowderCoating.Shared.Constants; using PowderCoating.Shared.Constants;
using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Identity;
@@ -8,6 +8,7 @@ using Microsoft.AspNetCore.Mvc.Rendering;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using PowderCoating.Application.Configuration; using PowderCoating.Application.Configuration;
using PowderCoating.Application.Services;
using PowderCoating.Application.DTOs.Accounting; using PowderCoating.Application.DTOs.Accounting;
using PowderCoating.Application.DTOs.AI; using PowderCoating.Application.DTOs.AI;
using PowderCoating.Application.Interfaces; using PowderCoating.Application.Interfaces;
@@ -15,6 +16,7 @@ using PowderCoating.Core.Entities;
using PowderCoating.Core.Enums; using PowderCoating.Core.Enums;
using PowderCoating.Core.Interfaces; using PowderCoating.Core.Interfaces;
using PowderCoating.Application.DTOs.PurchaseOrder; using PowderCoating.Application.DTOs.PurchaseOrder;
using PowderCoating.Web.Helpers;
namespace PowderCoating.Web.Controllers; namespace PowderCoating.Web.Controllers;
@@ -56,13 +58,13 @@ public class BillsController : Controller
_usageLogger = usageLogger; _usageLogger = usageLogger;
} }
// ── Index ──────────────────────────────────────────────────────────────── // -- Index ----------------------------------------------------------------
/// <summary> /// <summary>
/// Lists bills and direct expenses in a unified AP ledger view. The <paramref name="type"/> /// 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). /// 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 /// 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. /// Amount-based search strips leading $ and commas before comparing so "$1,234" works naturally.
/// </summary> /// </summary>
public async Task<IActionResult> Index(string? type, string? search, string? status, int page = 1, int pageSize = 25) public async Task<IActionResult> Index(string? type, string? search, string? status, int page = 1, int pageSize = 25)
@@ -110,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") if ((type == null || type == "Expense") && status != "Unpaid" && status != "Overdue")
{ {
var expSearch = search; var expSearch = search;
@@ -158,13 +160,13 @@ public class BillsController : Controller
return View(pagedEntries); return View(pagedEntries);
} }
// ── Create ─────────────────────────────────────────────────────────────── // -- Create ---------------------------------------------------------------
// ── Create from Purchase Order ──────────────────────────────────────────── // -- Create from Purchase Order --------------------------------------------
/// <summary> /// <summary>
/// Scaffolds a new bill pre-filled from a received purchase order. Only POs in /// 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 /// 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. /// 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 /// Line items are copied from PO items (using inventory item names where available), and
@@ -172,7 +174,7 @@ public class BillsController : Controller
/// <c>DefaultExpenseAccountId</c> is used to pre-categorise all lines, falling back to the /// <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. /// first active Expense/COGS account when the vendor has no default configured.
/// </summary> /// </summary>
[Authorize(Policy = AppConstants.Policies.CanManageInventory)] [Authorize(Policy = AppConstants.Policies.CanManageBills)]
public async Task<IActionResult> CreateFromPurchaseOrder(int purchaseOrderId) public async Task<IActionResult> CreateFromPurchaseOrder(int purchaseOrderId)
{ {
var currentUser = await _userManager.GetUserAsync(User); var currentUser = await _userManager.GetUserAsync(User);
@@ -246,7 +248,7 @@ public class BillsController : Controller
return View("Create", dto); return View("Create", dto);
} }
// ── Create ─────────────────────────────────────────────────────────────── // -- Create ---------------------------------------------------------------
/// <summary> /// <summary>
/// Returns the blank bill creation form. When <paramref name="vendorId"/> is supplied the /// Returns the blank bill creation form. When <paramref name="vendorId"/> is supplied the
@@ -255,7 +257,7 @@ public class BillsController : Controller
/// amount. The AP account is pre-filled with the first active AccountsPayable sub-type account /// 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. /// so the double-entry pair is ready without manual lookup.
/// </summary> /// </summary>
[Authorize(Policy = AppConstants.Policies.CanManageInventory)] [Authorize(Policy = AppConstants.Policies.CanManageBills)]
public async Task<IActionResult> Create(int? vendorId) public async Task<IActionResult> Create(int? vendorId)
{ {
var dto = new CreateBillDto var dto = new CreateBillDto
@@ -289,14 +291,14 @@ public class BillsController : Controller
/// review before committing to AP. Empty line items (zero account or zero price) are stripped /// 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. /// 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 /// 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 /// 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 /// 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 /// 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. /// back-reference <c>PurchaseOrder.BillId</c> is set to establish the 1:1 linkage.
/// </summary> /// </summary>
[HttpPost, ValidateAntiForgeryToken] [HttpPost, ValidateAntiForgeryToken]
[Authorize(Policy = AppConstants.Policies.CanManageInventory)] [Authorize(Policy = AppConstants.Policies.CanManageBills)]
public async Task<IActionResult> Create(CreateBillDto dto, IFormFile? receiptFile, public async Task<IActionResult> Create(CreateBillDto dto, IFormFile? receiptFile,
bool payNow = false, bool payNow = false,
DateTime? paymentDate = null, DateTime? paymentDate = null,
@@ -320,7 +322,25 @@ public class BillsController : Controller
{ {
var currentUser = await _userManager.GetUserAsync(User); var currentUser = await _userManager.GetUserAsync(User);
var bill = _mapper.Map<Bill>(dto); // 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
// atomically so a payNow failure cannot leave a bill with no payment record.
await _unitOfWork.ExecuteInTransactionAsync(async () =>
{
bill = _mapper.Map<Bill>(dto);
bill.BillNumber = await GenerateBillNumberAsync(); bill.BillNumber = await GenerateBillNumberAsync();
bill.Status = BillStatus.Open; bill.Status = BillStatus.Open;
bill.CompanyId = currentUser!.CompanyId; bill.CompanyId = currentUser!.CompanyId;
@@ -340,19 +360,9 @@ public class BillsController : Controller
bill.Total = bill.SubTotal + bill.TaxAmount; bill.Total = bill.SubTotal + bill.TaxAmount;
await _unitOfWork.Bills.AddAsync(bill); await _unitOfWork.Bills.AddAsync(bill);
await _unitOfWork.CompleteAsync(); await _unitOfWork.CompleteAsync(); // flush to get bill.Id
// Attach receipt file if provided // Link bill back to source PO
if (receiptFile != null && receiptFile.Length > 0)
{
if (IsValidReceiptFile(receiptFile, out var fileError))
bill.ReceiptFilePath = await UploadReceiptAsync(receiptFile, bill.Id, currentUser.CompanyId);
else
TempData["Warning"] = $"Bill saved but receipt not uploaded: {fileError}";
await _unitOfWork.CompleteAsync();
}
// Link bill back to source PO if created from one
if (dto.PurchaseOrderId > 0) if (dto.PurchaseOrderId > 0)
{ {
var po = await _unitOfWork.PurchaseOrders.GetByIdAsync(dto.PurchaseOrderId!.Value); var po = await _unitOfWork.PurchaseOrders.GetByIdAsync(dto.PurchaseOrderId!.Value);
@@ -360,7 +370,6 @@ public class BillsController : Controller
{ {
po.BillId = bill.Id; po.BillId = bill.Id;
po.UpdatedAt = DateTime.UtcNow; po.UpdatedAt = DateTime.UtcNow;
await _unitOfWork.CompleteAsync();
} }
} }
@@ -384,18 +393,31 @@ public class BillsController : Controller
bill.AmountPaid = payment.Amount; bill.AmountPaid = payment.Amount;
bill.Status = bill.AmountPaid >= bill.Total ? BillStatus.Paid : BillStatus.PartiallyPaid; bill.Status = bill.AmountPaid >= bill.Total ? BillStatus.Paid : BillStatus.PartiallyPaid;
await _unitOfWork.BillPayments.AddAsync(payment); await _unitOfWork.BillPayments.AddAsync(payment);
await _unitOfWork.CompleteAsync(); }
TempData["Success"] = $"Bill {bill.BillNumber} saved and marked as paid."; await _unitOfWork.CompleteAsync();
});
// 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)
{
var (receiptValid, _, receiptError) = BlobFileHelper.ValidateUpload(receiptFile, AllowedReceiptTypes, MaxReceiptBytes);
if (receiptValid)
{
bill!.ReceiptFilePath = await UploadReceiptAsync(receiptFile, bill.Id, currentUser.CompanyId);
await _unitOfWork.Bills.UpdateAsync(bill);
await _unitOfWork.CompleteAsync();
} }
else else
{ TempData["Warning"] = $"Bill saved but receipt not uploaded: {receiptError}";
TempData["Success"] = $"Bill {bill.BillNumber} created.";
} }
return RedirectToAction(nameof(Details), new { id = bill.Id }); TempData["Success"] = payNow && paymentMethod.HasValue && bankAccountId.HasValue
? $"Bill {bill!.BillNumber} saved and marked as paid."
: $"Bill {bill!.BillNumber} created.";
return RedirectToAction(nameof(Details), new { id = bill!.Id });
} }
catch (Exception ex) catch (Exception ex)
{ {
@@ -406,7 +428,7 @@ public class BillsController : Controller
} }
} }
// ── Details ────────────────────────────────────────────────────────────── // -- Details --------------------------------------------------------------
/// <summary> /// <summary>
/// Displays full bill detail including line items, payments, and the payment entry form. /// Displays full bill detail including line items, payments, and the payment entry form.
@@ -432,7 +454,7 @@ public class BillsController : Controller
.ToList(); .ToList();
ViewBag.BankAccounts = bankAccounts 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(); .ToList();
ViewBag.PaymentMethods = Enum.GetValues<PaymentMethod>() ViewBag.PaymentMethods = Enum.GetValues<PaymentMethod>()
@@ -442,7 +464,7 @@ public class BillsController : Controller
return View(dto); return View(dto);
} }
// ── Edit ───────────────────────────────────────────────────────────────── // -- Edit -----------------------------------------------------------------
/// <summary> /// <summary>
/// Returns the edit form for a bill. Only <c>Draft</c> bills are editable; once a bill is /// Returns the edit form for a bill. Only <c>Draft</c> bills are editable; once a bill is
@@ -450,7 +472,7 @@ public class BillsController : Controller
/// unreconciled ledger entries. Paid and Voided bills are also blocked to preserve the /// unreconciled ledger entries. Paid and Voided bills are also blocked to preserve the
/// audit trail. /// audit trail.
/// </summary> /// </summary>
[Authorize(Policy = AppConstants.Policies.CanManageInventory)] [Authorize(Policy = AppConstants.Policies.CanManageBills)]
public async Task<IActionResult> Edit(int? id) public async Task<IActionResult> Edit(int? id)
{ {
if (id == null) return NotFound(); if (id == null) return NotFound();
@@ -501,7 +523,7 @@ public class BillsController : Controller
/// storage; the old blob is deleted before the new one is written to avoid orphaned files. /// storage; the old blob is deleted before the new one is written to avoid orphaned files.
/// </summary> /// </summary>
[HttpPost, ValidateAntiForgeryToken] [HttpPost, ValidateAntiForgeryToken]
[Authorize(Policy = AppConstants.Policies.CanManageInventory)] [Authorize(Policy = AppConstants.Policies.CanManageBills)]
public async Task<IActionResult> Edit(int id, EditBillDto dto, IFormFile? receiptFile) public async Task<IActionResult> Edit(int id, EditBillDto dto, IFormFile? receiptFile)
{ {
if (id != dto.Id) return NotFound(); if (id != dto.Id) return NotFound();
@@ -571,7 +593,8 @@ public class BillsController : Controller
// Handle receipt file replacement // Handle receipt file replacement
if (receiptFile != null && receiptFile.Length > 0) if (receiptFile != null && receiptFile.Length > 0)
{ {
if (IsValidReceiptFile(receiptFile, out var fileError)) var (receiptValid, _, receiptError) = BlobFileHelper.ValidateUpload(receiptFile, AllowedReceiptTypes, MaxReceiptBytes);
if (receiptValid)
{ {
if (!string.IsNullOrEmpty(bill.ReceiptFilePath)) if (!string.IsNullOrEmpty(bill.ReceiptFilePath))
await _blobStorage.DeleteAsync(_storageSettings.Containers.ReceiptImages, bill.ReceiptFilePath); await _blobStorage.DeleteAsync(_storageSettings.Containers.ReceiptImages, bill.ReceiptFilePath);
@@ -579,7 +602,7 @@ public class BillsController : Controller
} }
else else
{ {
TempData["Warning"] = $"Bill saved but receipt not uploaded: {fileError}"; TempData["Warning"] = $"Bill saved but receipt not uploaded: {receiptError}";
} }
} }
@@ -597,7 +620,7 @@ public class BillsController : Controller
} }
} }
// ── Mark Open (Draft Open) ───────────────────────────────────────────── // -- Mark Open (Draft ? Open) ---------------------------------------------
/// <summary> /// <summary>
/// Transitions a bill from <c>Draft</c> to <c>Open</c> (the AP approval step). This is /// Transitions a bill from <c>Draft</c> to <c>Open</c> (the AP approval step). This is
@@ -608,7 +631,7 @@ public class BillsController : Controller
/// deferred from bill creation to give users a review window without polluting the ledger. /// deferred from bill creation to give users a review window without polluting the ledger.
/// </summary> /// </summary>
[HttpPost, ValidateAntiForgeryToken] [HttpPost, ValidateAntiForgeryToken]
[Authorize(Policy = AppConstants.Policies.CanManageInventory)] [Authorize(Policy = AppConstants.Policies.CanManageBills)]
public async Task<IActionResult> MarkOpen(int id) public async Task<IActionResult> MarkOpen(int id)
{ {
var bill = await _unitOfWork.Bills.GetByIdAsync(id, false, b => b.LineItems); var bill = await _unitOfWork.Bills.GetByIdAsync(id, false, b => b.LineItems);
@@ -646,7 +669,7 @@ public class BillsController : Controller
return RedirectToAction(nameof(Details), new { id }); return RedirectToAction(nameof(Details), new { id });
} }
// ── Record Payment ─────────────────────────────────────────────────────── // -- Record Payment -------------------------------------------------------
/// <summary> /// <summary>
/// Records a full or partial payment against an open bill. Overpayment is blocked because /// Records a full or partial payment against an open bill. Overpayment is blocked because
@@ -658,7 +681,7 @@ public class BillsController : Controller
/// any positive remainder leaves the bill in <c>PartiallyPaid</c>. /// any positive remainder leaves the bill in <c>PartiallyPaid</c>.
/// </summary> /// </summary>
[HttpPost, ValidateAntiForgeryToken] [HttpPost, ValidateAntiForgeryToken]
[Authorize(Policy = AppConstants.Policies.CanManageInventory)] [Authorize(Policy = AppConstants.Policies.CanManageBills)]
public async Task<IActionResult> RecordPayment(RecordBillPaymentDto dto) public async Task<IActionResult> RecordPayment(RecordBillPaymentDto dto)
{ {
if (!ModelState.IsValid) if (!ModelState.IsValid)
@@ -729,7 +752,7 @@ public class BillsController : Controller
return RedirectToAction(nameof(Details), new { id = dto.BillId }); return RedirectToAction(nameof(Details), new { id = dto.BillId });
} }
// ── Delete Payment ─────────────────────────────────────────────────────── // -- Delete Payment -------------------------------------------------------
/// <summary> /// <summary>
/// Reverses a previously recorded payment. All double-entry effects of /// Reverses a previously recorded payment. All double-entry effects of
@@ -739,7 +762,7 @@ public class BillsController : Controller
/// <c>PartiallyPaid</c> depending on the remaining <c>AmountPaid</c> after reversal. /// <c>PartiallyPaid</c> depending on the remaining <c>AmountPaid</c> after reversal.
/// </summary> /// </summary>
[HttpPost, ValidateAntiForgeryToken] [HttpPost, ValidateAntiForgeryToken]
[Authorize(Policy = AppConstants.Policies.CanManageInventory)] [Authorize(Policy = AppConstants.Policies.CanManageBills)]
public async Task<IActionResult> DeletePayment(int paymentId, int billId) public async Task<IActionResult> DeletePayment(int paymentId, int billId)
{ {
try try
@@ -786,7 +809,7 @@ public class BillsController : Controller
return RedirectToAction(nameof(Details), new { id = billId }); return RedirectToAction(nameof(Details), new { id = billId });
} }
// ── Edit Payment ───────────────────────────────────────────────────────── // -- Edit Payment ---------------------------------------------------------
/// <summary> /// <summary>
/// Updates non-financial attributes of a payment (date, method, check number, memo) and, /// Updates non-financial attributes of a payment (date, method, check number, memo) and,
@@ -795,7 +818,7 @@ public class BillsController : Controller
/// amount on the AP side does not change so no AP balance adjustment is needed. /// amount on the AP side does not change so no AP balance adjustment is needed.
/// </summary> /// </summary>
[HttpPost, ValidateAntiForgeryToken] [HttpPost, ValidateAntiForgeryToken]
[Authorize(Policy = AppConstants.Policies.CanManageInventory)] [Authorize(Policy = AppConstants.Policies.CanManageBills)]
public async Task<IActionResult> EditPayment(EditBillPaymentDto dto) public async Task<IActionResult> EditPayment(EditBillPaymentDto dto)
{ {
if (!ModelState.IsValid) if (!ModelState.IsValid)
@@ -840,11 +863,11 @@ public class BillsController : Controller
return RedirectToAction(nameof(Details), new { id = dto.BillId }); return RedirectToAction(nameof(Details), new { id = dto.BillId });
} }
// ── Void ───────────────────────────────────────────────────────────────── // -- Void -----------------------------------------------------------------
/// <summary> /// <summary>
/// Voids an open or partially-paid bill, removing the remaining AP liability from the ledger. /// 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 /// 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 /// 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> /// leaving a positive <c>BalanceDue</c>, <c>AmountPaid</c> is set equal to <c>Total</c>
@@ -899,7 +922,7 @@ public class BillsController : Controller
return RedirectToAction(nameof(Details), new { id }); return RedirectToAction(nameof(Details), new { id });
} }
// ── AJAX: Vendor default expense account ──────────────────────────────── // -- AJAX: Vendor default expense account --------------------------------
/// <summary> /// <summary>
/// AJAX endpoint that returns a vendor's default expense account and payment terms. Called by /// AJAX endpoint that returns a vendor's default expense account and payment terms. Called by
@@ -917,7 +940,7 @@ public class BillsController : Controller
}); });
} }
// ── Helpers ────────────────────────────────────────────────────────────── // -- Helpers --------------------------------------------------------------
/// <summary> /// <summary>
/// Loads all dropdown lists needed by the Create and Edit views into <c>ViewBag</c>: vendors, /// Loads all dropdown lists needed by the Create and Edit views into <c>ViewBag</c>: vendors,
@@ -927,48 +950,13 @@ public class BillsController : Controller
/// </summary> /// </summary>
private async Task PopulateDropdownsAsync() private async Task PopulateDropdownsAsync()
{ {
var vendors = await _unitOfWork.Vendors.FindAsync(s => s.IsActive); var dd = await AccountingDropdownHelper.LoadAsync(_unitOfWork);
ViewBag.Vendors = vendors ViewBag.Vendors = dd.Vendors;
.OrderBy(s => s.CompanyName) ViewBag.APAccounts = dd.ApAccounts;
.Select(s => new SelectListItem(s.CompanyName, s.Id.ToString())) ViewBag.ExpenseAccounts = dd.ExpenseAndAssetAccounts;
.ToList(); ViewBag.BankAccounts = dd.BankAccounts;
ViewBag.PaymentMethods = dd.PaymentMethods;
var allAccounts = await _unitOfWork.Accounts.FindAsync(a => a.IsActive); ViewBag.Jobs = dd.ActiveJobs;
ViewBag.APAccounts = allAccounts
.Where(a => a.AccountSubType == AccountSubType.AccountsPayable)
.OrderBy(a => a.AccountNumber)
.Select(a => new SelectListItem($"{a.AccountNumber} {a.Name}", a.Id.ToString()))
.ToList();
ViewBag.ExpenseAccounts = allAccounts
.Where(a => a.AccountType == AccountType.Expense ||
a.AccountType == AccountType.CostOfGoods ||
a.AccountType == AccountType.Asset)
.OrderBy(a => a.AccountNumber)
.Select(a => new SelectListItem($"{a.AccountNumber} {a.Name}", a.Id.ToString()))
.ToList();
ViewBag.BankAccounts = allAccounts
.Where(a => a.AccountSubType == AccountSubType.Cash ||
a.AccountSubType == AccountSubType.Checking ||
a.AccountSubType == AccountSubType.Savings ||
a.AccountSubType == AccountSubType.CreditCard)
.OrderBy(a => a.AccountNumber)
.Select(a => new SelectListItem($"{a.AccountNumber} {a.Name}", a.Id.ToString()))
.ToList();
ViewBag.PaymentMethods = Enum.GetValues<PaymentMethod>()
.Select(m => new SelectListItem(m.ToString(), ((int)m).ToString()))
.ToList();
ViewBag.Jobs = (await _unitOfWork.Jobs.FindAsync(j =>
j.JobStatus.StatusCode != "COMPLETED" &&
j.JobStatus.StatusCode != "CANCELLED" &&
j.JobStatus.StatusCode != "DELIVERED"))
.OrderBy(j => j.JobNumber)
.Select(j => new SelectListItem($"{j.JobNumber} {j.Description ?? "No description"}", j.Id.ToString()))
.ToList();
} }
/// <summary> /// <summary>
@@ -991,7 +979,7 @@ public class BillsController : Controller
/// <summary> /// <summary>
/// Generates a sequential payment reference number in the format <c>BPMT-YYMM-####</c>. /// 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. /// records are included in the scan so payment numbers are never reused.
/// </summary> /// </summary>
private async Task<string> GeneratePaymentNumberAsync() private async Task<string> GeneratePaymentNumberAsync()
@@ -1006,7 +994,7 @@ public class BillsController : Controller
return $"{prefix}{next:D4}"; return $"{prefix}{next:D4}";
} }
// ── Receipt File: Download / Remove ───────────────────────────────────── // -- Receipt File: Download / Remove -------------------------------------
/// <summary> /// <summary>
/// Downloads the receipt attachment for a bill as a file-download response. Unlike expense /// Downloads the receipt attachment for a bill as a file-download response. Unlike expense
@@ -1023,7 +1011,7 @@ public class BillsController : Controller
if (!result.Success) return NotFound(); if (!result.Success) return NotFound();
var ext = Path.GetExtension(bill.ReceiptFilePath).ToLowerInvariant(); var ext = Path.GetExtension(bill.ReceiptFilePath).ToLowerInvariant();
var contentType = MimeFromExt(ext); var contentType = BlobFileHelper.GetContentType(ext);
var fileName = $"receipt-{bill.BillNumber}{ext}"; var fileName = $"receipt-{bill.BillNumber}{ext}";
return File(result.Content, contentType, fileName); return File(result.Content, contentType, fileName);
} }
@@ -1034,7 +1022,7 @@ public class BillsController : Controller
/// window where the UI shows a broken attachment link. /// window where the UI shows a broken attachment link.
/// </summary> /// </summary>
[HttpPost, ValidateAntiForgeryToken] [HttpPost, ValidateAntiForgeryToken]
[Authorize(Policy = AppConstants.Policies.CanManageInventory)] [Authorize(Policy = AppConstants.Policies.CanManageBills)]
public async Task<IActionResult> RemoveReceipt(int id) public async Task<IActionResult> RemoveReceipt(int id)
{ {
var bill = await _unitOfWork.Bills.GetByIdAsync(id); var bill = await _unitOfWork.Bills.GetByIdAsync(id);
@@ -1051,7 +1039,7 @@ public class BillsController : Controller
return RedirectToAction(nameof(Details), new { id }); return RedirectToAction(nameof(Details), new { id });
} }
// ── AI: Receipt Scanning ───────────────────────────────────────────────── // -- AI: Receipt Scanning -------------------------------------------------
/// <summary> /// <summary>
/// AI-powered receipt scanning endpoint. Accepts an image or PDF of a vendor receipt, passes /// AI-powered receipt scanning endpoint. Accepts an image or PDF of a vendor receipt, passes
@@ -1063,7 +1051,7 @@ public class BillsController : Controller
/// model can match categories to the company's specific chart of accounts. /// model can match categories to the company's specific chart of accounts.
/// </summary> /// </summary>
[HttpPost] [HttpPost]
[Authorize(Policy = AppConstants.Policies.CanManageInventory)] [Authorize(Policy = AppConstants.Policies.CanManageBills)]
[EnableRateLimiting(AppConstants.RateLimitPolicies.Ai)] [EnableRateLimiting(AppConstants.RateLimitPolicies.Ai)]
public async Task<IActionResult> ScanReceipt(IFormFile? receiptImage) public async Task<IActionResult> ScanReceipt(IFormFile? receiptImage)
{ {
@@ -1104,7 +1092,7 @@ public class BillsController : Controller
return Json(result); return Json(result);
} }
// ── AI: Account Suggestion ──────────────────────────────────────────────── // -- AI: Account Suggestion ------------------------------------------------
/// <summary> /// <summary>
/// AI-powered account categorisation for a single bill line item. When the caller does not /// AI-powered account categorisation for a single bill line item. When the caller does not
@@ -1115,7 +1103,7 @@ public class BillsController : Controller
/// full account list in the DOM. Rate-limited to the <c>Ai</c> policy. /// full account list in the DOM. Rate-limited to the <c>Ai</c> policy.
/// </summary> /// </summary>
[HttpPost] [HttpPost]
[Authorize(Policy = AppConstants.Policies.CanManageInventory)] [Authorize(Policy = AppConstants.Policies.CanManageBills)]
[EnableRateLimiting(AppConstants.RateLimitPolicies.Ai)] [EnableRateLimiting(AppConstants.RateLimitPolicies.Ai)]
public async Task<IActionResult> SuggestAccount([FromBody] AccountSuggestionRequest request) public async Task<IActionResult> SuggestAccount([FromBody] AccountSuggestionRequest request)
{ {
@@ -1148,7 +1136,69 @@ public class BillsController : Controller
return Json(result); 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> /// <summary>
/// Uploads a receipt file to Azure Blob Storage under the path /// Uploads a receipt file to Azure Blob Storage under the path
@@ -1161,41 +1211,8 @@ public class BillsController : Controller
{ {
var ext = Path.GetExtension(file.FileName).ToLowerInvariant(); var ext = Path.GetExtension(file.FileName).ToLowerInvariant();
var blobName = $"{companyId}/bill-receipts/{billId}/{Guid.NewGuid()}{ext}"; var blobName = $"{companyId}/bill-receipts/{billId}/{Guid.NewGuid()}{ext}";
var contentType = MimeFromExt(ext);
using var stream = file.OpenReadStream(); using var stream = file.OpenReadStream();
var result = await _blobStorage.UploadAsync(_storageSettings.Containers.ReceiptImages, blobName, stream, contentType); var result = await _blobStorage.UploadAsync(_storageSettings.Containers.ReceiptImages, blobName, stream, BlobFileHelper.GetContentType(ext));
return result.Success ? blobName : null; return result.Success ? blobName : null;
} }
/// <summary>
/// Validates a receipt file upload against the allowed extension list and the 10 MB size cap.
/// Returns <c>false</c> and populates <paramref name="error"/> with a user-friendly message
/// when the file fails either check; returns <c>true</c> and sets <paramref name="error"/> to
/// an empty string when the file is acceptable.
/// </summary>
private static bool IsValidReceiptFile(IFormFile file, out string error)
{
var ext = Path.GetExtension(file.FileName).ToLowerInvariant();
if (!AllowedReceiptTypes.Contains(ext))
{
error = $"File type '{ext}' is not allowed. Accepted: {string.Join(", ", AllowedReceiptTypes)}";
return false;
}
if (file.Length > MaxReceiptBytes)
{
error = "Receipt file must be 10 MB or smaller.";
return false;
}
error = string.Empty;
return true;
}
private static string MimeFromExt(string ext) => ext switch
{
".pdf" => "application/pdf",
".png" => "image/png",
".gif" => "image/gif",
".webp" => "image/webp",
_ => "image/jpeg"
};
} }
@@ -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;
}
@@ -82,6 +82,8 @@ public class CompaniesController : Controller
{ {
var ids = companyDtos.Select(c => c.Id).ToList(); var ids = companyDtos.Select(c => c.Id).ToList();
var summary = await _companyList.GetCountSummaryAsync(ids); var summary = await _companyList.GetCountSummaryAsync(ids);
var companyById = companies.ToDictionary(c => c.Id);
var now = DateTime.UtcNow;
foreach (var dto in companyDtos) foreach (var dto in companyDtos)
{ {
@@ -95,6 +97,23 @@ public class CompaniesController : Controller
dto.WizardCompletedAt = w.CompletedAt; dto.WizardCompletedAt = w.CompletedAt;
dto.WizardCompletedByName = w.CompletedByName; 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;
} }
} }
@@ -183,7 +202,8 @@ public class CompaniesController : Controller
.GetByIdAsync(id, ignoreQueryFilters: true, .GetByIdAsync(id, ignoreQueryFilters: true,
c => c.Users, c => c.Users,
c => c.Customers, c => c.Customers,
c => c.Jobs); c => c.Jobs,
c => c.Preferences!);
if (company == null) if (company == null)
{ {
@@ -196,6 +216,51 @@ public class CompaniesController : Controller
ViewBag.PlanConfigs = (await _unitOfWork.SubscriptionPlanConfigs.FindAsync( ViewBag.PlanConfigs = (await _unitOfWork.SubscriptionPlanConfigs.FindAsync(
c => c.IsActive, ignoreQueryFilters: true)).OrderBy(c => c.SortOrder).ToList(); 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); return View(companyDto);
} }
catch (Exception ex) catch (Exception ex)
@@ -118,15 +118,12 @@ public class CompanyHealthController : Controller
var tquotes = totalQuotes.TryGetValue(c.Id, out var tq) ? tq : 0; 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 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 var neverActivated = tjobs == 0 && tcust == 0 && tquotes == 0
&& c.CreatedAt < now.AddDays(-7); && c.CreatedAt < now.AddDays(-7);
var riskLevel = neverActivated ? ChurnRisk.NeverActivated var riskLevel = CompanyHealthHelper.ToRiskLevel(score, neverActivated);
: score >= 75 ? ChurnRisk.Healthy
: score >= 45 ? ChurnRisk.AtRisk
: ChurnRisk.Critical;
var configHealth = configHealthMap.TryGetValue(c.Id, out var ch) var configHealth = configHealthMap.TryGetValue(c.Id, out var ch)
? ch : new CompanyConfigHealth { CompanyId = c.Id }; ? ch : new CompanyConfigHealth { CompanyId = c.Id };
@@ -187,112 +184,10 @@ public class CompanyHealthController : Controller
return View(all); 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 ──────────────────────────────────────────────────────────────── // ── View models ────────────────────────────────────────────────────────────────
public enum ChurnRisk { Healthy, AtRisk, Critical, NeverActivated }
public class CompanyHealthDto public class CompanyHealthDto
{ {
public int Id { get; set; } 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 UpdatedAt = t.UpdatedAt
}).ToList(); }).ToList();
ViewBag.BookLockedThrough = company.BookLockedThrough.HasValue
? (DateTime?)company.BookLockedThrough.Value.ToLocalTime()
: null;
return View(dto); return View(dto);
} }
catch (FormatException fex) 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> /// <summary>
/// Serves the current company's logo as a binary file response. Logos are stored on the filesystem /// 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> /// via <see cref="ICompanyLogoService"/> (primary) or as raw bytes in <c>Company.LogoData</c>
@@ -174,14 +174,7 @@ public class CompanyUsersController : Controller
LastLoginDate = u.LastLoginDate LastLoginDate = u.LastLoginDate
}).ToList(); }).ToList();
// Create paged result var pagedResult = PagedResult<CompanyUserListDto>.From(gridRequest, userDtos, totalCount);
var pagedResult = new PagedResult<CompanyUserListDto>
{
Items = userDtos,
PageNumber = gridRequest.PageNumber,
PageSize = gridRequest.PageSize,
TotalCount = totalCount
};
// Set ViewBag for sorting and filters // Set ViewBag for sorting and filters
ViewBag.SearchTerm = searchTerm; ViewBag.SearchTerm = searchTerm;
@@ -284,6 +277,7 @@ public class CompanyUsersController : Controller
{ {
AppConstants.CompanyRoles.CompanyAdmin, AppConstants.CompanyRoles.CompanyAdmin,
AppConstants.CompanyRoles.Manager, AppConstants.CompanyRoles.Manager,
AppConstants.CompanyRoles.Accountant,
AppConstants.CompanyRoles.Worker, AppConstants.CompanyRoles.Worker,
AppConstants.CompanyRoles.Viewer AppConstants.CompanyRoles.Viewer
}; };
@@ -336,7 +330,9 @@ public class CompanyUsersController : Controller
CanManageVendors = forceAllPermissions || model.CanManageVendors, CanManageVendors = forceAllPermissions || model.CanManageVendors,
CanManageMaintenance = forceAllPermissions || model.CanManageMaintenance, CanManageMaintenance = forceAllPermissions || model.CanManageMaintenance,
CanManageInvoices = forceAllPermissions || model.CanManageInvoices, 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); var result = await _userManager.CreateAsync(user, model.Password);
@@ -348,6 +344,7 @@ public class CompanyUsersController : Controller
{ {
AppConstants.CompanyRoles.CompanyAdmin => AppConstants.Roles.Administrator, AppConstants.CompanyRoles.CompanyAdmin => AppConstants.Roles.Administrator,
AppConstants.CompanyRoles.Manager => AppConstants.Roles.Manager, AppConstants.CompanyRoles.Manager => AppConstants.Roles.Manager,
AppConstants.CompanyRoles.Accountant => AppConstants.Roles.Employee,
AppConstants.CompanyRoles.Worker => AppConstants.Roles.Employee, AppConstants.CompanyRoles.Worker => AppConstants.Roles.Employee,
_ => AppConstants.Roles.ReadOnly _ => AppConstants.Roles.ReadOnly
}; };
@@ -461,7 +458,9 @@ public class CompanyUsersController : Controller
CanManageVendors = user.CanManageVendors, CanManageVendors = user.CanManageVendors,
CanManageMaintenance = user.CanManageMaintenance, CanManageMaintenance = user.CanManageMaintenance,
CanManageInvoices = user.CanManageInvoices, CanManageInvoices = user.CanManageInvoices,
CanViewReports = user.CanViewReports CanViewReports = user.CanViewReports,
CanManageBills = user.CanManageBills,
CanManageAccounting = user.CanManageAccounting
}; };
ViewBag.ReturnUrl = returnUrl; ViewBag.ReturnUrl = returnUrl;
@@ -545,6 +544,7 @@ public class CompanyUsersController : Controller
{ {
AppConstants.CompanyRoles.CompanyAdmin, AppConstants.CompanyRoles.CompanyAdmin,
AppConstants.CompanyRoles.Manager, AppConstants.CompanyRoles.Manager,
AppConstants.CompanyRoles.Accountant,
AppConstants.CompanyRoles.Worker, AppConstants.CompanyRoles.Worker,
AppConstants.CompanyRoles.Viewer AppConstants.CompanyRoles.Viewer
}; };
@@ -615,6 +615,8 @@ public class CompanyUsersController : Controller
user.CanManageMaintenance = forceAllPermissions || model.CanManageMaintenance; user.CanManageMaintenance = forceAllPermissions || model.CanManageMaintenance;
user.CanManageInvoices = forceAllPermissions || model.CanManageInvoices; user.CanManageInvoices = forceAllPermissions || model.CanManageInvoices;
user.CanViewReports = forceAllPermissions || model.CanViewReports; user.CanViewReports = forceAllPermissions || model.CanViewReports;
user.CanManageBills = forceAllPermissions || model.CanManageBills;
user.CanManageAccounting = forceAllPermissions || model.CanManageAccounting;
user.UpdatedAt = DateTime.UtcNow; user.UpdatedAt = DateTime.UtcNow;
var result = await _userManager.UpdateAsync(user); 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 ISubscriptionService _subscriptionService;
private readonly ITenantContext _tenantContext; private readonly ITenantContext _tenantContext;
private readonly UserManager<ApplicationUser> _userManager; private readonly UserManager<ApplicationUser> _userManager;
private readonly IFinancialReportService _financialReports;
public CustomersController( public CustomersController(
IUnitOfWork unitOfWork, IUnitOfWork unitOfWork,
@@ -34,7 +35,8 @@ public class CustomersController : Controller
INotificationService notificationService, INotificationService notificationService,
ISubscriptionService subscriptionService, ISubscriptionService subscriptionService,
ITenantContext tenantContext, ITenantContext tenantContext,
UserManager<ApplicationUser> userManager) UserManager<ApplicationUser> userManager,
IFinancialReportService financialReports)
{ {
_unitOfWork = unitOfWork; _unitOfWork = unitOfWork;
_mapper = mapper; _mapper = mapper;
@@ -43,6 +45,7 @@ public class CustomersController : Controller
_subscriptionService = subscriptionService; _subscriptionService = subscriptionService;
_tenantContext = tenantContext; _tenantContext = tenantContext;
_userManager = userManager; _userManager = userManager;
_financialReports = financialReports;
} }
/// <summary> /// <summary>
@@ -123,14 +126,7 @@ public class CustomersController : Controller
LastContactDate = c.LastContactDate LastContactDate = c.LastContactDate
}).ToList(); }).ToList();
// Create paged result var pagedResult = PagedResult<CustomerListDto>.From(gridRequest, customerDtos, totalCount);
var pagedResult = new PagedResult<CustomerListDto>
{
Items = customerDtos,
PageNumber = gridRequest.PageNumber,
PageSize = gridRequest.PageSize,
TotalCount = totalCount
};
// Set ViewBag for sorting // Set ViewBag for sorting
ViewBag.SearchTerm = searchTerm; ViewBag.SearchTerm = searchTerm;
@@ -942,6 +938,30 @@ public class CustomersController : Controller
return RedirectToAction(nameof(Details), new { id }); 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> /// <summary>
/// Generates the next sequential credit memo number in CM-YYMM-#### format. /// Generates the next sequential credit memo number in CM-YYMM-#### format.
/// Uses <c>ignoreQueryFilters: true</c> when scanning all existing memos so that /// Uses <c>ignoreQueryFilters: true</c> when scanning all existing memos so that
@@ -950,13 +970,18 @@ public class CustomersController : Controller
/// </summary> /// </summary>
private async Task<string> GenerateCreditMemoNumberAsync() private async Task<string> GenerateCreditMemoNumberAsync()
{ {
var allMemos = await _unitOfWork.CreditMemos.GetAllAsync(true);
var prefix = $"CM-{DateTime.Now:yyMM}-"; var prefix = $"CM-{DateTime.Now:yyMM}-";
var maxNum = allMemos var last = (await _unitOfWork.CreditMemos.FindAsync(
.Where(m => m.MemoNumber.StartsWith(prefix)) m => m.MemoNumber.StartsWith(prefix), ignoreQueryFilters: true))
.Select(m => { int.TryParse(m.MemoNumber.Replace(prefix, ""), out int n); return n; }) .OrderByDescending(m => m.MemoNumber)
.DefaultIfEmpty(0).Max(); .Select(m => m.MemoNumber)
return $"{prefix}{(maxNum + 1):D4}"; .FirstOrDefault();
int next = 1;
if (last != null && int.TryParse(last[prefix.Length..], out int num))
next = num + 1;
return $"{prefix}{next:D4}";
} }
/// <summary> /// <summary>

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