Compare commits

..

31 Commits

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 20:50:11 -04:00
106 changed files with 81367 additions and 983 deletions
@@ -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>
@@ -59,6 +59,9 @@ public class CompanyPreferencesDto
// Blank Work Order PDF Template // Blank Work Order PDF Template
public string WoAccentColor { get; set; } = "#374151"; public string WoAccentColor { get; set; } = "#374151";
public string? WoTerms { get; set; } public string? WoTerms { get; set; }
// Kiosk settings
public string KioskIntakeOutput { get; set; } = "Quote";
} }
public class UpdateAppDefaultsDto public class UpdateAppDefaultsDto
@@ -136,3 +139,11 @@ public class UpdateWorkOrderTemplateDto
public string WoAccentColor { get; set; } = "#374151"; public string WoAccentColor { get; set; } = "#374151";
[StringLength(2000)] public string? WoTerms { get; set; } [StringLength(2000)] public string? WoTerms { get; set; }
} }
public class UpdateKioskSettingsDto
{
/// <summary>"Quote" (default) or "Job" — what the kiosk creates on submission.</summary>
[Required]
public string KioskIntakeOutput { get; set; } = "Quote";
}
@@ -32,7 +32,9 @@ public class InvoiceDto
public string CustomerName { get; set; } = string.Empty; public string CustomerName { get; set; } = string.Empty;
public string? CustomerEmail { get; set; } public string? CustomerEmail { get; set; }
public string? CustomerPhone { get; set; } public string? CustomerPhone { get; set; }
public string? CustomerMobilePhone { get; set; }
public bool CustomerNotifyByEmail { get; set; } public bool CustomerNotifyByEmail { get; set; }
public bool CustomerNotifyBySms { get; set; }
public string? PreparedById { get; set; } public string? PreparedById { get; set; }
public string? PreparedByName { get; set; } public string? PreparedByName { get; set; }
public InvoiceStatus Status { get; set; } public InvoiceStatus Status { get; set; }
@@ -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; }
@@ -0,0 +1,88 @@
using System.ComponentModel.DataAnnotations;
using PowderCoating.Core.Enums;
namespace PowderCoating.Application.DTOs.Kiosk;
// ── Staff-facing ──────────────────────────────────────────────────────────────
/// <summary>Input for sending a remote intake link to a customer by email.</summary>
public class SendRemoteLinkDto
{
[Required, EmailAddress]
public string Email { get; set; } = string.Empty;
/// <summary>Optional — used to personalise the email greeting.</summary>
public string? CustomerName { get; set; }
}
// ── Customer-facing step DTOs ─────────────────────────────────────────────────
/// <summary>Step 1 — Contact information submitted by the customer.</summary>
public class SubmitKioskContactDto
{
[Required, MaxLength(100)]
public string FirstName { get; set; } = string.Empty;
[Required, MaxLength(100)]
public string LastName { get; set; } = string.Empty;
[Required, Phone]
public string Phone { get; set; } = string.Empty;
[Required, EmailAddress]
public string Email { get; set; } = string.Empty;
public bool IsReturningCustomer { get; set; }
}
/// <summary>Step 2 — Job description submitted by the customer.</summary>
public class SubmitKioskJobDto
{
[Required, MaxLength(2000)]
public string JobDescription { get; set; } = string.Empty;
public string? HowDidYouHearAboutUs { get; set; }
}
/// <summary>Step 3 — Terms agreement (+ optional drawn signature for in-person sessions).</summary>
public class SubmitKioskTermsDto
{
[Required]
[Range(typeof(bool), "true", "true", ErrorMessage = "You must agree to the terms to continue.")]
public bool AgreedToTerms { get; set; }
public bool SmsOptIn { get; set; }
/// <summary>Base-64 PNG from signature_pad; required for InPerson sessions, null for Remote.</summary>
public string? SignatureDataBase64 { get; set; }
}
// ── Staff review list ─────────────────────────────────────────────────────────
/// <summary>One row in the Kiosk Intakes staff review list.</summary>
public class KioskSessionListDto
{
public int Id { get; set; }
public Guid SessionToken { get; set; }
public KioskSessionType SessionType { get; set; }
public KioskSessionStatus Status { get; set; }
public string CustomerFirstName { get; set; } = string.Empty;
public string CustomerLastName { get; set; } = string.Empty;
public string CustomerEmail { get; set; } = string.Empty;
public string CustomerPhone { get; set; } = string.Empty;
public string JobDescription { get; set; } = string.Empty;
public bool SmsOptIn { get; set; }
public DateTime? SubmittedAt { get; set; }
public DateTime ExpiresAt { get; set; }
public int? LinkedCustomerId { get; set; }
public int? LinkedJobId { get; set; }
public int? LinkedQuoteId { get; set; }
public string? RemoteLinkEmail { get; set; }
public string CustomerFullName => $"{CustomerFirstName} {CustomerLastName}".Trim();
public string JobDescriptionSnippet =>
JobDescription.Length > 80 ? JobDescription[..80] + "…" : JobDescription;
public bool IsConverted => LinkedJobId.HasValue || LinkedQuoteId.HasValue;
public bool IsExpired => Status == KioskSessionStatus.Expired ||
(Status == KioskSessionStatus.Active && DateTime.UtcNow > ExpiresAt);
}
@@ -58,7 +58,7 @@ public interface INotificationService
/// Notify customer when an invoice has been sent. /// Notify customer when an invoice has been sent.
/// Optionally includes an online payment link in the email body. /// Optionally includes an online payment link in the email body.
/// </summary> /// </summary>
Task NotifyInvoiceSentAsync(Invoice invoice, byte[]? pdfAttachment = null, string? pdfFilename = null, string? paymentUrl = null, string? overrideEmail = null); Task NotifyInvoiceSentAsync(Invoice invoice, byte[]? pdfAttachment = null, string? pdfFilename = null, string? paymentUrl = null, string? overrideEmail = null, bool sendSms = false, string? viewUrl = null);
/// <summary> /// <summary>
/// Notify customer (internal) when a payment has been recorded on an invoice. /// Notify customer (internal) when a payment has been recorded on an invoice.
@@ -54,5 +54,6 @@ public class CompanyProfile : Profile
CreateMap<UpdateQuoteTemplateDto, CompanyPreferences>(); CreateMap<UpdateQuoteTemplateDto, CompanyPreferences>();
CreateMap<UpdateInvoiceTemplateDto, CompanyPreferences>(); CreateMap<UpdateInvoiceTemplateDto, CompanyPreferences>();
CreateMap<UpdateWorkOrderTemplateDto, CompanyPreferences>(); CreateMap<UpdateWorkOrderTemplateDto, CompanyPreferences>();
CreateMap<UpdateKioskSettingsDto, CompanyPreferences>();
} }
} }
@@ -28,7 +28,9 @@ public class InvoiceProfile : Profile
? (s.Customer.BillingEmail ?? s.Customer.Email) ? (s.Customer.BillingEmail ?? s.Customer.Email)
: null)) : null))
.ForMember(d => d.CustomerPhone, o => o.MapFrom(s => s.Customer != null ? s.Customer.Phone : null)) .ForMember(d => d.CustomerPhone, o => o.MapFrom(s => s.Customer != null ? s.Customer.Phone : null))
.ForMember(d => d.CustomerMobilePhone, o => o.MapFrom(s => s.Customer != null ? s.Customer.MobilePhone : null))
.ForMember(d => d.CustomerNotifyByEmail, o => o.MapFrom(s => s.Customer == null || s.Customer.NotifyByEmail)) .ForMember(d => d.CustomerNotifyByEmail, o => o.MapFrom(s => s.Customer == null || s.Customer.NotifyByEmail))
.ForMember(d => d.CustomerNotifyBySms, o => o.MapFrom(s => s.Customer != null && s.Customer.NotifyBySms))
.ForMember(d => d.PreparedByName, o => o.MapFrom(s => s.PreparedBy != null .ForMember(d => d.PreparedByName, o => o.MapFrom(s => s.PreparedBy != null
? $"{s.PreparedBy.FirstName} {s.PreparedBy.LastName}".Trim() ? $"{s.PreparedBy.FirstName} {s.PreparedBy.LastName}".Trim()
: null)) : null))
@@ -246,6 +246,8 @@ public class VendorCredit : BaseEntity
public decimal Total { get; set; } public decimal Total { get; set; }
public decimal RemainingAmount { get; set; } public decimal RemainingAmount { get; set; }
public string? Memo { 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 // Navigation
public virtual Vendor Vendor { get; set; } = null!; public virtual Vendor Vendor { get; set; } = null!;
@@ -123,6 +123,16 @@ public class Company : BaseEntity
public byte[]? LogoData { get; set; } // Legacy - kept for backward compatibility public byte[]? LogoData { get; set; } // Legacy - kept for backward compatibility
public string? LogoContentType { get; set; } // Legacy - kept for backward compatibility public string? LogoContentType { get; set; } // Legacy - kept for backward compatibility
public string? LogoFilePath { get; set; } // Filesystem path: /media/{CompanyId}/company-logo.{ext} public string? LogoFilePath { get; set; } // Filesystem path: /media/{CompanyId}/company-logo.{ext}
// Kiosk
/// <summary>
/// Random token written to a long-lived HttpOnly cookie on the front-desk tablet when the
/// owner activates the kiosk. Kiosk routes validate this token against the cookie so the
/// tablet can serve the intake form without requiring a logged-in user.
/// Null = kiosk not activated. Regenerate to revoke the current device.
/// </summary>
public string? KioskActivationToken { get; set; }
// Navigation Properties // Navigation Properties
public virtual ICollection<ApplicationUser> Users { get; set; } = new List<ApplicationUser>(); public virtual ICollection<ApplicationUser> Users { get; set; } = new List<ApplicationUser>();
public virtual ICollection<Customer> Customers { get; set; } = new List<Customer>(); public virtual ICollection<Customer> Customers { get; set; } = new List<Customer>();
@@ -86,6 +86,14 @@ public class CompanyPreferences : BaseEntity
/// <summary>JSON blob persisting QB Migration Wizard step state across sessions.</summary> /// <summary>JSON blob persisting QB Migration Wizard step state across sessions.</summary>
public string? QbMigrationStateJson { get; set; } public string? QbMigrationStateJson { get; set; }
// Kiosk settings
/// <summary>
/// Controls what the kiosk creates on submission: "Quote" (default) or "Job".
/// Quote aligns with the default Terms text ("subject to a formal quote").
/// Job is for shops that price on the spot and want the work order ready immediately.
/// </summary>
public string KioskIntakeOutput { get; set; } = "Quote";
// Guided activation / first-workflow onboarding // Guided activation / first-workflow onboarding
/// <summary>Selected first-workflow path: quote_first or job_first. Null until chosen.</summary> /// <summary>Selected first-workflow path: quote_first or job_first. Null until chosen.</summary>
public string? OnboardingPath { get; set; } public string? OnboardingPath { get; set; }
@@ -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; }
@@ -28,6 +28,13 @@ public class Invoice : BaseEntity
public decimal GiftCertificateRedeemed { get; set; } // Sum of gift certificate redemptions public decimal GiftCertificateRedeemed { get; set; } // Sum of gift certificate redemptions
public decimal BalanceDue => Total - AmountPaid - CreditApplied - GiftCertificateRedeemed; public decimal BalanceDue => Total - AmountPaid - CreditApplied - GiftCertificateRedeemed;
/// <summary>
/// Permanent public token for the customer-facing invoice view page (/invoice/{token}).
/// Generated when the invoice is first sent (regardless of Stripe status) and never expires.
/// Distinct from PaymentLinkToken which is Stripe-gated and expires in 5 days.
/// </summary>
public string? PublicViewToken { get; set; }
// Online payments (Stripe Connect) // Online payments (Stripe Connect)
public OnlinePaymentStatus OnlinePaymentStatus { get; set; } = OnlinePaymentStatus.NotApplicable; public OnlinePaymentStatus OnlinePaymentStatus { get; set; } = OnlinePaymentStatus.NotApplicable;
public string? PaymentLinkToken { get; set; } // Signed token for /pay/{token} public string? PaymentLinkToken { get; set; } // Signed token for /pay/{token}
@@ -0,0 +1,54 @@
using PowderCoating.Core.Enums;
namespace PowderCoating.Core.Entities;
/// <summary>
/// Represents one customer self-service intake session — either completed on the front-desk tablet
/// (InPerson) or via an emailed link the customer fills out on their own device (Remote).
/// Sessions are tenant-scoped and soft-deletable. Load anonymous sessions with ignoreQueryFilters:true.
/// </summary>
public class KioskSession : BaseEntity
{
/// <summary>URL-safe GUID used in all kiosk routes; unique across the table.</summary>
public Guid SessionToken { get; set; } = Guid.NewGuid();
public KioskSessionType SessionType { get; set; }
public KioskSessionStatus Status { get; set; } = KioskSessionStatus.Active;
// ── Step 1 — Contact ─────────────────────────────────────────────────────
public string CustomerFirstName { get; set; } = string.Empty;
public string CustomerLastName { get; set; } = string.Empty;
public string CustomerPhone { get; set; } = string.Empty;
public string CustomerEmail { get; set; } = string.Empty;
public bool IsReturningCustomer { get; set; }
// ── Step 2 — Job Description ──────────────────────────────────────────────
public string JobDescription { get; set; } = string.Empty;
public string? HowDidYouHearAboutUs { get; set; }
// ── Step 3 — Terms & Consent ──────────────────────────────────────────────
public bool AgreedToTerms { get; set; }
public DateTime? AgreedToTermsAt { get; set; }
/// <summary>Customer opted in to SMS order updates; sets Customer.NotifyBySms on submission.</summary>
public bool SmsOptIn { get; set; }
/// <summary>Base-64 PNG from signature_pad; null for Remote sessions (no drawn signature required).</summary>
public string? SignatureDataBase64 { get; set; }
// ── Outcome ───────────────────────────────────────────────────────────────
public int? LinkedCustomerId { get; set; }
/// <summary>Set when KioskIntakeOutput = "Job". Null when a Quote was created instead.</summary>
public int? LinkedJobId { get; set; }
/// <summary>Set when KioskIntakeOutput = "Quote". Null when a Job was created instead.</summary>
public int? LinkedQuoteId { get; set; }
public DateTime? SubmittedAt { get; set; }
/// <summary>Sessions auto-expire 2 h after creation (InPerson) or 48 h (Remote). ExpiresAt is set at creation.</summary>
public DateTime ExpiresAt { get; set; }
// ── Remote-only ───────────────────────────────────────────────────────────
public string? RemoteLinkEmail { get; set; }
public DateTime? RemoteLinkSentAt { get; set; }
// ── Navigation ────────────────────────────────────────────────────────────
public virtual Customer? LinkedCustomer { get; set; }
public virtual Job? LinkedJob { get; set; }
}
@@ -22,6 +22,10 @@ public class Refund : BaseEntity
public DateTime? IssuedDate { get; set; } public 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; }
@@ -0,0 +1,15 @@
namespace PowderCoating.Core.Enums;
public enum KioskSessionType
{
InPerson = 0,
Remote = 1
}
public enum KioskSessionStatus
{
Active = 0,
Submitted = 1,
Expired = 2,
Cancelled = 3
}
@@ -154,6 +154,9 @@ public interface IUnitOfWork : IDisposable
IRepository<GiftCertificate> GiftCertificates { get; } IRepository<GiftCertificate> GiftCertificates { get; }
IRepository<GiftCertificateRedemption> GiftCertificateRedemptions { get; } IRepository<GiftCertificateRedemption> GiftCertificateRedemptions { get; }
// Customer Intake Kiosk
IRepository<KioskSession> KioskSessions { get; }
Task<int> SaveChangesAsync(); Task<int> SaveChangesAsync();
Task<int> CompleteAsync(); // Alias for SaveChangesAsync Task<int> CompleteAsync(); // Alias for SaveChangesAsync
@@ -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>
@@ -367,6 +367,10 @@ public class ApplicationDbContext : IdentityDbContext<ApplicationUser>, IDataPro
/// <summary>Prep-service definitions within a job template item.</summary> /// <summary>Prep-service definitions within a job template item.</summary>
public DbSet<JobTemplateItemPrepService> JobTemplateItemPrepServices { get; set; } public DbSet<JobTemplateItemPrepService> JobTemplateItemPrepServices { get; set; }
// Customer Intake Kiosk
/// <summary>Customer self-service intake sessions (walk-in tablet or remote email link); tenant-filtered with soft delete.</summary>
public DbSet<KioskSession> KioskSessions { get; set; }
/// <summary> /// <summary>
/// Platform-wide audit log capturing who changed what and when, across all tenants. /// Platform-wide audit log capturing who changed what and when, across all tenants.
/// No global query filter — SuperAdmin controllers query this directly. /// No global query filter — SuperAdmin controllers query this directly.
@@ -746,6 +750,24 @@ public class ApplicationDbContext : IdentityDbContext<ApplicationUser>, IDataPro
modelBuilder.Entity<InAppNotification>().HasQueryFilter(e => modelBuilder.Entity<InAppNotification>().HasQueryFilter(e =>
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId)); !e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
// Customer intake kiosk sessions — tenant-filtered + soft delete.
// Anonymous intake routes must use ignoreQueryFilters:true when loading by SessionToken.
modelBuilder.Entity<KioskSession>().HasQueryFilter(e =>
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
modelBuilder.Entity<KioskSession>()
.HasIndex(e => e.SessionToken)
.IsUnique();
modelBuilder.Entity<KioskSession>()
.HasOne(k => k.LinkedCustomer)
.WithMany()
.HasForeignKey(k => k.LinkedCustomerId)
.OnDelete(DeleteBehavior.SetNull);
modelBuilder.Entity<KioskSession>()
.HasOne(k => k.LinkedJob)
.WithMany()
.HasForeignKey(k => k.LinkedJobId)
.OnDelete(DeleteBehavior.SetNull);
// Account self-referencing hierarchy // Account self-referencing hierarchy
modelBuilder.Entity<Account>() modelBuilder.Entity<Account>()
.HasOne(a => a.ParentAccount) .HasOne(a => a.ParentAccount)
@@ -967,6 +967,17 @@ New accounts walk through an 18-step setup wizard to configure company informati
CreatedAt = DateTime.UtcNow CreatedAt = DateTime.UtcNow
}, },
new NotificationTemplate new NotificationTemplate
{
NotificationType = NotificationType.InvoiceSent,
Channel = NotificationChannel.Sms,
DisplayName = "Invoice Sent (SMS)",
Subject = null,
Body = "{{companyName}}: Invoice {{invoiceNumber}} for {{invoiceTotal}} is ready. View your invoice: {{viewUrl}} Reply STOP to opt out.",
IsActive = true,
CompanyId = companyId,
CreatedAt = DateTime.UtcNow
},
new NotificationTemplate
{ {
NotificationType = NotificationType.PaymentReceived, NotificationType = NotificationType.PaymentReceived,
Channel = NotificationChannel.Email, Channel = NotificationChannel.Email,
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,88 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace PowderCoating.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class AddMissingPlatformSettings : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
// Conditional inserts — safe to run against a DB that already has some of these keys set manually.
migrationBuilder.Sql(@"
IF NOT EXISTS (SELECT 1 FROM PlatformSettings WHERE [Key] = 'SmsEnabled')
INSERT INTO PlatformSettings ([Key],[Value],[Label],[Description],[GroupName])
VALUES ('SmsEnabled','false','SMS Enabled','Platform-level switch for outbound SMS. When off, no SMS messages are sent regardless of company settings.','Notifications');
IF NOT EXISTS (SELECT 1 FROM PlatformSettings WHERE [Key] = 'TrialsEnabled')
INSERT INTO PlatformSettings ([Key],[Value],[Label],[Description],[GroupName])
VALUES ('TrialsEnabled','true','Trials Enabled','Allow new companies to register with a free trial period. When off, registration requires a paid plan immediately.','Subscriptions');
IF NOT EXISTS (SELECT 1 FROM PlatformSettings WHERE [Key] = 'GracePeriodDays')
INSERT INTO PlatformSettings ([Key],[Value],[Label],[Description],[GroupName])
VALUES ('GracePeriodDays','14','Grace Period (days)','Days after subscription expiry before access is fully cut off. Gives companies time to renew without an abrupt lockout.','Subscriptions');
IF NOT EXISTS (SELECT 1 FROM PlatformSettings WHERE [Key] = 'GracePeriodAppliesToTrials')
INSERT INTO PlatformSettings ([Key],[Value],[Label],[Description],[GroupName])
VALUES ('GracePeriodAppliesToTrials','false','Grace Period Applies to Trials','When enabled, trial companies also receive the grace period after expiry rather than being cut off immediately.','Subscriptions');
IF NOT EXISTS (SELECT 1 FROM PlatformSettings WHERE [Key] = 'MaxTenants')
INSERT INTO PlatformSettings ([Key],[Value],[Label],[Description],[GroupName])
VALUES ('MaxTenants','-1','Max Tenants','Maximum number of active tenant companies allowed on the platform. Set to -1 for no limit.','Subscriptions');
IF NOT EXISTS (SELECT 1 FROM PlatformSettings WHERE [Key] = 'AiCatalogPriceCheckEnabled')
INSERT INTO PlatformSettings ([Key],[Value],[Label],[Description],[GroupName])
VALUES ('AiCatalogPriceCheckEnabled','true','AI Catalog Price Check','Platform-level switch for the AI catalog price review feature. When off, the feature is disabled for all companies regardless of their settings.','AI');
");
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 1,
column: "CreatedAt",
value: new DateTime(2026, 5, 13, 1, 34, 45, 450, DateTimeKind.Utc).AddTicks(8377));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 2,
column: "CreatedAt",
value: new DateTime(2026, 5, 13, 1, 34, 45, 450, DateTimeKind.Utc).AddTicks(8383));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 3,
column: "CreatedAt",
value: new DateTime(2026, 5, 13, 1, 34, 45, 450, DateTimeKind.Utc).AddTicks(8385));
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 1,
column: "CreatedAt",
value: new DateTime(2026, 5, 11, 14, 23, 23, 221, DateTimeKind.Utc).AddTicks(5837));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 2,
column: "CreatedAt",
value: new DateTime(2026, 5, 11, 14, 23, 23, 221, DateTimeKind.Utc).AddTicks(5846));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 3,
column: "CreatedAt",
value: new DateTime(2026, 5, 11, 14, 23, 23, 221, DateTimeKind.Utc).AddTicks(5847));
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,95 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace PowderCoating.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class SeedSalesDiscountsAccount : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
// Insert the 4950 Sales Discounts contra-revenue account for every company that does
// not already have it. The account is credit-normal (AccountType=4 Revenue,
// AccountSubType=32 OtherIncome) and is debited when invoice discounts are applied so
// the GL balances (DR Sales Discounts / gap between CR Revenue and DR AR).
// Idempotent: the WHERE NOT EXISTS guard means re-running the migration is safe.
migrationBuilder.Sql(@"
INSERT INTO Accounts
(AccountNumber, Name, AccountType, AccountSubType,
IsSystem, IsActive, Description,
CompanyId, CreatedAt, IsDeleted,
CurrentBalance, OpeningBalance)
SELECT
'4950',
'Sales Discounts',
4, -- AccountType.Revenue
32, -- AccountSubType.OtherIncome
1, -- IsSystem = true
1, -- IsActive = true
'Contra-revenue for invoice discounts granted to customers',
c.Id,
GETUTCDATE(),
0, -- IsDeleted = false
0, -- CurrentBalance
0 -- OpeningBalance
FROM Companies c
WHERE c.IsDeleted = 0
AND NOT EXISTS (
SELECT 1 FROM Accounts a
WHERE a.CompanyId = c.Id
AND a.AccountNumber = '4950'
AND a.IsDeleted = 0
);
");
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 1,
column: "CreatedAt",
value: new DateTime(2026, 5, 13, 13, 39, 22, 61, DateTimeKind.Utc).AddTicks(8475));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 2,
column: "CreatedAt",
value: new DateTime(2026, 5, 13, 13, 39, 22, 61, DateTimeKind.Utc).AddTicks(8484));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 3,
column: "CreatedAt",
value: new DateTime(2026, 5, 13, 13, 39, 22, 61, DateTimeKind.Utc).AddTicks(8486));
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 1,
column: "CreatedAt",
value: new DateTime(2026, 5, 13, 1, 34, 45, 450, DateTimeKind.Utc).AddTicks(8377));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 2,
column: "CreatedAt",
value: new DateTime(2026, 5, 13, 1, 34, 45, 450, DateTimeKind.Utc).AddTicks(8383));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 3,
column: "CreatedAt",
value: new DateTime(2026, 5, 13, 1, 34, 45, 450, DateTimeKind.Utc).AddTicks(8385));
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,113 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace PowderCoating.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class AccountingGapsPhase2 : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<DateTime>(
name: "PostedDate",
table: "VendorCredits",
type: "datetime2",
nullable: true);
migrationBuilder.AddColumn<int>(
name: "DepositAccountId",
table: "Refunds",
type: "int",
nullable: true);
// Seed the Gift Certificate Liability account (2500) for every company that doesn't
// already have it. Credit-normal OtherCurrentLiability account; credited when a GC is
// issued and debited when redeemed or voided. Idempotent guard prevents double-seeding.
migrationBuilder.Sql(@"
INSERT INTO Accounts
(AccountNumber, Name, AccountType, AccountSubType,
IsSystem, IsActive, Description,
CompanyId, CreatedAt, IsDeleted,
CurrentBalance, OpeningBalance)
SELECT
'2500',
'Gift Certificate Liability',
2, -- AccountType.Liability
12, -- AccountSubType.OtherCurrentLiability
1, -- IsSystem = true
1, -- IsActive = true
'Outstanding gift certificate obligations owed to certificate holders',
c.Id,
GETUTCDATE(),
0, -- IsDeleted = false
0, -- CurrentBalance
0 -- OpeningBalance
FROM Companies c
WHERE c.IsDeleted = 0
AND NOT EXISTS (
SELECT 1 FROM Accounts a
WHERE a.CompanyId = c.Id
AND a.AccountNumber = '2500'
AND a.IsDeleted = 0
);
");
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 1,
column: "CreatedAt",
value: new DateTime(2026, 5, 13, 14, 24, 44, 715, DateTimeKind.Utc).AddTicks(9166));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 2,
column: "CreatedAt",
value: new DateTime(2026, 5, 13, 14, 24, 44, 715, DateTimeKind.Utc).AddTicks(9172));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 3,
column: "CreatedAt",
value: new DateTime(2026, 5, 13, 14, 24, 44, 715, DateTimeKind.Utc).AddTicks(9174));
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "PostedDate",
table: "VendorCredits");
migrationBuilder.DropColumn(
name: "DepositAccountId",
table: "Refunds");
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 1,
column: "CreatedAt",
value: new DateTime(2026, 5, 13, 13, 39, 22, 61, DateTimeKind.Utc).AddTicks(8475));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 2,
column: "CreatedAt",
value: new DateTime(2026, 5, 13, 13, 39, 22, 61, DateTimeKind.Utc).AddTicks(8484));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 3,
column: "CreatedAt",
value: new DateTime(2026, 5, 13, 13, 39, 22, 61, DateTimeKind.Utc).AddTicks(8486));
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,103 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace PowderCoating.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class AccountingDepositsGL : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<int>(
name: "DepositAccountId",
table: "Deposits",
type: "int",
nullable: true);
// Seed account 2300 "Customer Deposits" (Liability / OtherCurrentLiability) for every
// company that doesn't already have it. Credited when a deposit is taken; debited when
// the deposit is applied to an invoice. Idempotent guard prevents double-seeding.
migrationBuilder.Sql(@"
INSERT INTO Accounts
(AccountNumber, Name, AccountType, AccountSubType,
IsSystem, IsActive, Description,
CompanyId, CreatedAt, IsDeleted,
CurrentBalance, OpeningBalance)
SELECT
'2300',
'Customer Deposits',
2, -- AccountType.Liability
12, -- AccountSubType.OtherCurrentLiability
1, -- IsSystem = true
1, -- IsActive = true
'Deposits received from customers before an invoice is created; cleared when deposit is applied to invoice',
c.Id,
GETUTCDATE(),
0, -- IsDeleted = false
0, -- CurrentBalance
0 -- OpeningBalance
FROM Companies c
WHERE c.IsDeleted = 0
AND NOT EXISTS (
SELECT 1 FROM Accounts a
WHERE a.CompanyId = c.Id
AND a.AccountNumber = '2300'
AND a.IsDeleted = 0
);
");
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 1,
column: "CreatedAt",
value: new DateTime(2026, 5, 13, 14, 57, 30, 15, DateTimeKind.Utc).AddTicks(5641));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 2,
column: "CreatedAt",
value: new DateTime(2026, 5, 13, 14, 57, 30, 15, DateTimeKind.Utc).AddTicks(5655));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 3,
column: "CreatedAt",
value: new DateTime(2026, 5, 13, 14, 57, 30, 15, DateTimeKind.Utc).AddTicks(5656));
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "DepositAccountId",
table: "Deposits");
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 1,
column: "CreatedAt",
value: new DateTime(2026, 5, 13, 14, 24, 44, 715, DateTimeKind.Utc).AddTicks(9166));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 2,
column: "CreatedAt",
value: new DateTime(2026, 5, 13, 14, 24, 44, 715, DateTimeKind.Utc).AddTicks(9172));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 3,
column: "CreatedAt",
value: new DateTime(2026, 5, 13, 14, 24, 44, 715, DateTimeKind.Utc).AddTicks(9174));
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,142 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace PowderCoating.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class AddKioskIntakeSession : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "KioskActivationToken",
table: "Companies",
type: "nvarchar(max)",
nullable: true);
migrationBuilder.CreateTable(
name: "KioskSessions",
columns: table => new
{
Id = table.Column<int>(type: "int", nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
SessionToken = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
SessionType = table.Column<int>(type: "int", nullable: false),
Status = table.Column<int>(type: "int", nullable: false),
CustomerFirstName = table.Column<string>(type: "nvarchar(max)", nullable: false),
CustomerLastName = table.Column<string>(type: "nvarchar(max)", nullable: false),
CustomerPhone = table.Column<string>(type: "nvarchar(max)", nullable: false),
CustomerEmail = table.Column<string>(type: "nvarchar(max)", nullable: false),
IsReturningCustomer = table.Column<bool>(type: "bit", nullable: false),
JobDescription = table.Column<string>(type: "nvarchar(max)", nullable: false),
HowDidYouHearAboutUs = table.Column<string>(type: "nvarchar(max)", nullable: true),
AgreedToTerms = table.Column<bool>(type: "bit", nullable: false),
AgreedToTermsAt = table.Column<DateTime>(type: "datetime2", nullable: true),
SmsOptIn = table.Column<bool>(type: "bit", nullable: false),
SignatureDataBase64 = table.Column<string>(type: "nvarchar(max)", nullable: true),
LinkedCustomerId = table.Column<int>(type: "int", nullable: true),
LinkedJobId = table.Column<int>(type: "int", nullable: true),
SubmittedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
ExpiresAt = table.Column<DateTime>(type: "datetime2", nullable: false),
RemoteLinkEmail = table.Column<string>(type: "nvarchar(max)", nullable: true),
RemoteLinkSentAt = table.Column<DateTime>(type: "datetime2", nullable: true),
CompanyId = table.Column<int>(type: "int", nullable: false),
CreatedAt = table.Column<DateTime>(type: "datetime2", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
CreatedBy = table.Column<string>(type: "nvarchar(max)", nullable: true),
UpdatedBy = table.Column<string>(type: "nvarchar(max)", nullable: true),
IsDeleted = table.Column<bool>(type: "bit", nullable: false),
DeletedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
DeletedBy = table.Column<string>(type: "nvarchar(max)", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_KioskSessions", x => x.Id);
table.ForeignKey(
name: "FK_KioskSessions_Customers_LinkedCustomerId",
column: x => x.LinkedCustomerId,
principalTable: "Customers",
principalColumn: "Id",
onDelete: ReferentialAction.SetNull);
table.ForeignKey(
name: "FK_KioskSessions_Jobs_LinkedJobId",
column: x => x.LinkedJobId,
principalTable: "Jobs",
principalColumn: "Id",
onDelete: ReferentialAction.SetNull);
});
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 1,
column: "CreatedAt",
value: new DateTime(2026, 5, 13, 18, 40, 15, 633, DateTimeKind.Utc).AddTicks(8207));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 2,
column: "CreatedAt",
value: new DateTime(2026, 5, 13, 18, 40, 15, 633, DateTimeKind.Utc).AddTicks(8213));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 3,
column: "CreatedAt",
value: new DateTime(2026, 5, 13, 18, 40, 15, 633, DateTimeKind.Utc).AddTicks(8215));
migrationBuilder.CreateIndex(
name: "IX_KioskSessions_LinkedCustomerId",
table: "KioskSessions",
column: "LinkedCustomerId");
migrationBuilder.CreateIndex(
name: "IX_KioskSessions_LinkedJobId",
table: "KioskSessions",
column: "LinkedJobId");
migrationBuilder.CreateIndex(
name: "IX_KioskSessions_SessionToken",
table: "KioskSessions",
column: "SessionToken",
unique: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "KioskSessions");
migrationBuilder.DropColumn(
name: "KioskActivationToken",
table: "Companies");
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 1,
column: "CreatedAt",
value: new DateTime(2026, 5, 13, 14, 57, 30, 15, DateTimeKind.Utc).AddTicks(5641));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 2,
column: "CreatedAt",
value: new DateTime(2026, 5, 13, 14, 57, 30, 15, DateTimeKind.Utc).AddTicks(5655));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 3,
column: "CreatedAt",
value: new DateTime(2026, 5, 13, 14, 57, 30, 15, DateTimeKind.Utc).AddTicks(5656));
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,71 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace PowderCoating.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class AddInvoicePublicViewToken : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "PublicViewToken",
table: "Invoices",
type: "nvarchar(max)",
nullable: true);
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 1,
column: "CreatedAt",
value: new DateTime(2026, 5, 13, 19, 28, 44, 26, DateTimeKind.Utc).AddTicks(4259));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 2,
column: "CreatedAt",
value: new DateTime(2026, 5, 13, 19, 28, 44, 26, DateTimeKind.Utc).AddTicks(4264));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 3,
column: "CreatedAt",
value: new DateTime(2026, 5, 13, 19, 28, 44, 26, DateTimeKind.Utc).AddTicks(4266));
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "PublicViewToken",
table: "Invoices");
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 1,
column: "CreatedAt",
value: new DateTime(2026, 5, 13, 18, 40, 15, 633, DateTimeKind.Utc).AddTicks(8207));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 2,
column: "CreatedAt",
value: new DateTime(2026, 5, 13, 18, 40, 15, 633, DateTimeKind.Utc).AddTicks(8213));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 3,
column: "CreatedAt",
value: new DateTime(2026, 5, 13, 18, 40, 15, 633, DateTimeKind.Utc).AddTicks(8215));
}
}
}
@@ -0,0 +1,82 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace PowderCoating.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class AddKioskIntakeOutputSetting : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<int>(
name: "LinkedQuoteId",
table: "KioskSessions",
type: "int",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "KioskIntakeOutput",
table: "CompanyPreferences",
type: "nvarchar(max)",
nullable: false,
defaultValue: "");
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 1,
column: "CreatedAt",
value: new DateTime(2026, 5, 14, 2, 27, 27, 993, DateTimeKind.Utc).AddTicks(2349));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 2,
column: "CreatedAt",
value: new DateTime(2026, 5, 14, 2, 27, 27, 993, DateTimeKind.Utc).AddTicks(2366));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 3,
column: "CreatedAt",
value: new DateTime(2026, 5, 14, 2, 27, 27, 993, DateTimeKind.Utc).AddTicks(2367));
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "LinkedQuoteId",
table: "KioskSessions");
migrationBuilder.DropColumn(
name: "KioskIntakeOutput",
table: "CompanyPreferences");
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 1,
column: "CreatedAt",
value: new DateTime(2026, 5, 13, 19, 28, 44, 26, DateTimeKind.Utc).AddTicks(4259));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 2,
column: "CreatedAt",
value: new DateTime(2026, 5, 13, 19, 28, 44, 26, DateTimeKind.Utc).AddTicks(4264));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 3,
column: "CreatedAt",
value: new DateTime(2026, 5, 13, 19, 28, 44, 26, DateTimeKind.Utc).AddTicks(4266));
}
}
}
@@ -1812,6 +1812,9 @@ namespace PowderCoating.Infrastructure.Migrations
b.Property<bool>("IsDeleted") b.Property<bool>("IsDeleted")
.HasColumnType("bit"); .HasColumnType("bit");
b.Property<string>("KioskActivationToken")
.HasColumnType("nvarchar(max)");
b.Property<string>("LogoContentType") b.Property<string>("LogoContentType")
.HasColumnType("nvarchar(max)"); .HasColumnType("nvarchar(max)");
@@ -2250,6 +2253,10 @@ namespace PowderCoating.Infrastructure.Migrations
b.Property<int>("JobRetentionYears") b.Property<int>("JobRetentionYears")
.HasColumnType("int"); .HasColumnType("int");
b.Property<string>("KioskIntakeOutput")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<int>("LogRetentionDays") b.Property<int>("LogRetentionDays")
.HasColumnType("int"); .HasColumnType("int");
@@ -2892,6 +2899,9 @@ namespace PowderCoating.Infrastructure.Migrations
b.Property<string>("DeletedBy") b.Property<string>("DeletedBy")
.HasColumnType("nvarchar(max)"); .HasColumnType("nvarchar(max)");
b.Property<int?>("DepositAccountId")
.HasColumnType("int");
b.Property<bool>("IsDeleted") b.Property<bool>("IsDeleted")
.HasColumnType("bit"); .HasColumnType("bit");
@@ -3916,6 +3926,9 @@ namespace PowderCoating.Infrastructure.Migrations
b.Property<string>("PreparedById") b.Property<string>("PreparedById")
.HasColumnType("nvarchar(450)"); .HasColumnType("nvarchar(450)");
b.Property<string>("PublicViewToken")
.HasColumnType("nvarchar(max)");
b.Property<int?>("SalesTaxAccountId") b.Property<int?>("SalesTaxAccountId")
.HasColumnType("int"); .HasColumnType("int");
@@ -5561,6 +5574,118 @@ namespace PowderCoating.Infrastructure.Migrations
b.ToTable("JournalEntryLines"); b.ToTable("JournalEntryLines");
}); });
modelBuilder.Entity("PowderCoating.Core.Entities.KioskSession", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<bool>("AgreedToTerms")
.HasColumnType("bit");
b.Property<DateTime?>("AgreedToTermsAt")
.HasColumnType("datetime2");
b.Property<int>("CompanyId")
.HasColumnType("int");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime2");
b.Property<string>("CreatedBy")
.HasColumnType("nvarchar(max)");
b.Property<string>("CustomerEmail")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<string>("CustomerFirstName")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<string>("CustomerLastName")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<string>("CustomerPhone")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<DateTime?>("DeletedAt")
.HasColumnType("datetime2");
b.Property<string>("DeletedBy")
.HasColumnType("nvarchar(max)");
b.Property<DateTime>("ExpiresAt")
.HasColumnType("datetime2");
b.Property<string>("HowDidYouHearAboutUs")
.HasColumnType("nvarchar(max)");
b.Property<bool>("IsDeleted")
.HasColumnType("bit");
b.Property<bool>("IsReturningCustomer")
.HasColumnType("bit");
b.Property<string>("JobDescription")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<int?>("LinkedCustomerId")
.HasColumnType("int");
b.Property<int?>("LinkedJobId")
.HasColumnType("int");
b.Property<int?>("LinkedQuoteId")
.HasColumnType("int");
b.Property<string>("RemoteLinkEmail")
.HasColumnType("nvarchar(max)");
b.Property<DateTime?>("RemoteLinkSentAt")
.HasColumnType("datetime2");
b.Property<Guid>("SessionToken")
.HasColumnType("uniqueidentifier");
b.Property<int>("SessionType")
.HasColumnType("int");
b.Property<string>("SignatureDataBase64")
.HasColumnType("nvarchar(max)");
b.Property<bool>("SmsOptIn")
.HasColumnType("bit");
b.Property<int>("Status")
.HasColumnType("int");
b.Property<DateTime?>("SubmittedAt")
.HasColumnType("datetime2");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("datetime2");
b.Property<string>("UpdatedBy")
.HasColumnType("nvarchar(max)");
b.HasKey("Id");
b.HasIndex("LinkedCustomerId");
b.HasIndex("LinkedJobId");
b.HasIndex("SessionToken")
.IsUnique();
b.ToTable("KioskSessions");
});
modelBuilder.Entity("PowderCoating.Core.Entities.MaintenanceRecord", b => modelBuilder.Entity("PowderCoating.Core.Entities.MaintenanceRecord", b =>
{ {
b.Property<int>("Id") b.Property<int>("Id")
@@ -6574,7 +6699,7 @@ namespace PowderCoating.Infrastructure.Migrations
{ {
Id = 1, Id = 1,
CompanyId = 0, CompanyId = 0,
CreatedAt = new DateTime(2026, 5, 11, 14, 23, 23, 221, DateTimeKind.Utc).AddTicks(5837), CreatedAt = new DateTime(2026, 5, 14, 2, 27, 27, 993, DateTimeKind.Utc).AddTicks(2349),
Description = "Standard pricing for regular customers", Description = "Standard pricing for regular customers",
DiscountPercent = 0m, DiscountPercent = 0m,
IsActive = true, IsActive = true,
@@ -6585,7 +6710,7 @@ namespace PowderCoating.Infrastructure.Migrations
{ {
Id = 2, Id = 2,
CompanyId = 0, CompanyId = 0,
CreatedAt = new DateTime(2026, 5, 11, 14, 23, 23, 221, DateTimeKind.Utc).AddTicks(5846), CreatedAt = new DateTime(2026, 5, 14, 2, 27, 27, 993, DateTimeKind.Utc).AddTicks(2366),
Description = "5% discount for preferred customers", Description = "5% discount for preferred customers",
DiscountPercent = 5m, DiscountPercent = 5m,
IsActive = true, IsActive = true,
@@ -6596,7 +6721,7 @@ namespace PowderCoating.Infrastructure.Migrations
{ {
Id = 3, Id = 3,
CompanyId = 0, CompanyId = 0,
CreatedAt = new DateTime(2026, 5, 11, 14, 23, 23, 221, DateTimeKind.Utc).AddTicks(5847), CreatedAt = new DateTime(2026, 5, 14, 2, 27, 27, 993, DateTimeKind.Utc).AddTicks(2367),
Description = "10% discount for premium customers", Description = "10% discount for premium customers",
DiscountPercent = 10m, DiscountPercent = 10m,
IsActive = true, IsActive = true,
@@ -7654,6 +7779,9 @@ namespace PowderCoating.Infrastructure.Migrations
b.Property<string>("DeletedBy") b.Property<string>("DeletedBy")
.HasColumnType("nvarchar(max)"); .HasColumnType("nvarchar(max)");
b.Property<int?>("DepositAccountId")
.HasColumnType("int");
b.Property<int>("InvoiceId") b.Property<int>("InvoiceId")
.HasColumnType("int"); .HasColumnType("int");
@@ -8384,6 +8512,9 @@ namespace PowderCoating.Infrastructure.Migrations
b.Property<string>("Memo") b.Property<string>("Memo")
.HasColumnType("nvarchar(max)"); .HasColumnType("nvarchar(max)");
b.Property<DateTime?>("PostedDate")
.HasColumnType("datetime2");
b.Property<decimal>("RemainingAmount") b.Property<decimal>("RemainingAmount")
.HasColumnType("decimal(18,2)"); .HasColumnType("decimal(18,2)");
@@ -9712,6 +9843,23 @@ namespace PowderCoating.Infrastructure.Migrations
b.Navigation("JournalEntry"); b.Navigation("JournalEntry");
}); });
modelBuilder.Entity("PowderCoating.Core.Entities.KioskSession", b =>
{
b.HasOne("PowderCoating.Core.Entities.Customer", "LinkedCustomer")
.WithMany()
.HasForeignKey("LinkedCustomerId")
.OnDelete(DeleteBehavior.SetNull);
b.HasOne("PowderCoating.Core.Entities.Job", "LinkedJob")
.WithMany()
.HasForeignKey("LinkedJobId")
.OnDelete(DeleteBehavior.SetNull);
b.Navigation("LinkedCustomer");
b.Navigation("LinkedJob");
});
modelBuilder.Entity("PowderCoating.Core.Entities.MaintenanceRecord", b => modelBuilder.Entity("PowderCoating.Core.Entities.MaintenanceRecord", b =>
{ {
b.HasOne("PowderCoating.Core.Entities.ApplicationUser", "AssignedUser") b.HasOne("PowderCoating.Core.Entities.ApplicationUser", "AssignedUser")
@@ -121,6 +121,9 @@ public class UnitOfWork : IUnitOfWork
private IRepository<GiftCertificate>? _giftCertificates; private IRepository<GiftCertificate>? _giftCertificates;
private IRepository<GiftCertificateRedemption>? _giftCertificateRedemptions; private IRepository<GiftCertificateRedemption>? _giftCertificateRedemptions;
// Customer Intake Kiosk
private IRepository<KioskSession>? _kioskSessions;
// Purchase Orders // Purchase Orders
private IPurchaseOrderRepository? _purchaseOrders; private IPurchaseOrderRepository? _purchaseOrders;
private IRepository<PurchaseOrderItem>? _purchaseOrderItems; private IRepository<PurchaseOrderItem>? _purchaseOrderItems;
@@ -460,6 +463,10 @@ public class UnitOfWork : IUnitOfWork
public IRepository<GiftCertificateRedemption> GiftCertificateRedemptions => public IRepository<GiftCertificateRedemption> GiftCertificateRedemptions =>
_giftCertificateRedemptions ??= new Repository<GiftCertificateRedemption>(_context); _giftCertificateRedemptions ??= new Repository<GiftCertificateRedemption>(_context);
/// <summary>Repository for <see cref="KioskSession"/> customer self-service intake sessions; tenant-filtered with soft delete.</summary>
public IRepository<KioskSession> KioskSessions =>
_kioskSessions ??= new Repository<KioskSession>(_context);
// Job Templates // Job Templates
/// <summary>Repository for <see cref="JobTemplate"/> reusable job blueprints; tenant-filtered with soft delete.</summary> /// <summary>Repository for <see cref="JobTemplate"/> reusable job blueprints; tenant-filtered with soft delete.</summary>
public IJobTemplateRepository JobTemplates => public IJobTemplateRepository JobTemplates =>
@@ -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);
} }
} }
@@ -79,6 +79,53 @@ public class FinancialReportService : IFinancialReportService
.SumAsync(ii => (decimal?)ii.TotalPrice) ?? 0; .SumAsync(ii => (decimal?)ii.TotalPrice) ?? 0;
if (unlinkedRevenue > 0) if (unlinkedRevenue > 0)
revenueLines.Add(new FinancialReportLine { AccountNumber = "—", AccountName = "Other Sales (unclassified)", Amount = unlinkedRevenue }); revenueLines.Add(new FinancialReportLine { AccountNumber = "—", AccountName = "Other Sales (unclassified)", Amount = unlinkedRevenue });
// Contra-revenue: discounts granted and credit memos applied reduce gross revenue.
var periodDiscounts = await _context.Invoices
.Where(i => i.Status != InvoiceStatus.Draft && i.Status != InvoiceStatus.Voided
&& i.DiscountAmount > 0 && i.InvoiceDate >= from && i.InvoiceDate <= toEnd)
.SumAsync(i => (decimal?)i.DiscountAmount) ?? 0m;
var periodCredits = await _context.CreditMemoApplications
.Where(a => a.AppliedDate >= from && a.AppliedDate <= toEnd
&& a.Invoice.Status != InvoiceStatus.Voided)
.SumAsync(a => (decimal?)a.AmountApplied) ?? 0m;
var totalDeductions = periodDiscounts + periodCredits;
if (totalDeductions > 0)
revenueLines.Add(new FinancialReportLine
{
AccountNumber = "4950",
AccountName = "Less: Sales Discounts & Credits",
Amount = -totalDeductions
});
// GC sales are deferred to GC Liability at issuance; revenue is recognized on redemption.
var periodGcReclassified = await _context.InvoiceItems
.Where(ii => ii.IsGiftCertificate
&& ii.Invoice.Status != InvoiceStatus.Draft
&& ii.Invoice.Status != InvoiceStatus.Voided
&& ii.Invoice.InvoiceDate >= from && ii.Invoice.InvoiceDate <= toEnd)
.SumAsync(ii => (decimal?)ii.TotalPrice) ?? 0m;
if (periodGcReclassified > 0)
revenueLines.Add(new FinancialReportLine
{
AccountNumber = "2500",
AccountName = "Less: Gift Certificates Issued (Deferred Revenue)",
Amount = -periodGcReclassified
});
// Voided GCs with remaining balance are breakage income (liability extinguished).
var periodGcBreakage = await _context.GiftCertificates
.Where(gc => !gc.IsDeleted && gc.Status == GiftCertificateStatus.Voided
&& gc.UpdatedAt >= from && gc.UpdatedAt <= toEnd
&& gc.OriginalAmount > gc.RedeemedAmount)
.SumAsync(gc => (decimal?)(gc.OriginalAmount - gc.RedeemedAmount)) ?? 0m;
if (periodGcBreakage > 0)
revenueLines.Add(new FinancialReportLine
{
AccountNumber = "—",
AccountName = "Gift Certificate Breakage Income",
Amount = periodGcBreakage
});
} }
// COGS & Expenses — cash basis: expenses paid in period; accrual: by bill/expense date // COGS & Expenses — cash basis: expenses paid in period; accrual: by bill/expense date
@@ -200,6 +247,13 @@ public class FinancialReportService : IFinancialReportService
.Select(g => new { Id = g.Key, Amount = g.Sum(bp => bp.Amount) }) .Select(g => new { Id = g.Key, Amount = g.Sum(bp => bp.Amount) })
.ToDictionaryAsync(g => g.Id, g => g.Amount); .ToDictionaryAsync(g => g.Id, g => g.Amount);
// AP: vendor credit applications reduce AP (DR side) when matched against specific bills.
var vcByApAcctBs = await _context.VendorCreditApplications
.Where(vca => vca.AppliedDate <= asOfEnd)
.GroupBy(vca => vca.VendorCredit.APAccountId)
.Select(g => new { Id = g.Key, Amount = g.Sum(vca => vca.Amount) })
.ToDictionaryAsync(g => g.Id, g => g.Amount);
var taxByAcct = await _context.Invoices var taxByAcct = await _context.Invoices
.Where(i => i.SalesTaxAccountId != null && i.TaxAmount > 0 .Where(i => i.SalesTaxAccountId != null && i.TaxAmount > 0
&& i.Status != InvoiceStatus.Draft && i.Status != InvoiceStatus.Voided && i.Status != InvoiceStatus.Draft && i.Status != InvoiceStatus.Voided
@@ -216,18 +270,131 @@ public class FinancialReportService : IFinancialReportService
&& p.Invoice.Status != InvoiceStatus.Voided && p.Invoice.Status != InvoiceStatus.Voided
&& p.Invoice.Status != InvoiceStatus.WrittenOff) && p.Invoice.Status != InvoiceStatus.WrittenOff)
.SumAsync(p => (decimal?)p.Amount) ?? 0; .SumAsync(p => (decimal?)p.Amount) ?? 0;
// Credit memo applications reduce open AR (CR AR when a credit is applied to an invoice).
arCredits += await _context.CreditMemoApplications
.Where(a => a.AppliedDate <= asOfEnd && a.Invoice.Status != InvoiceStatus.Voided)
.SumAsync(a => (decimal?)a.AmountApplied) ?? 0;
// Refunds reverse collected payments — they re-open AR so reduce net AR credits.
arCredits -= await _context.Refunds
.Where(r => r.RefundDate <= asOfEnd && !r.IsDeleted)
.SumAsync(r => (decimal?)r.Amount) ?? 0m;
// Retained earnings = net P&L from inception through asOf // Refunds by bank account: money that left the account (CR to checking/bank).
var refundsByAcctBs = await _context.Refunds
.Where(r => r.RefundDate <= asOfEnd && !r.IsDeleted && r.DepositAccountId != null)
.GroupBy(r => r.DepositAccountId!.Value)
.Select(g => new { Id = g.Key, Amount = g.Sum(r => r.Amount) })
.ToDictionaryAsync(g => g.Id, g => g.Amount);
// Deposits by bank account: cash received at deposit recording time (DR bank).
var depositsByAcctDepBs = await _context.Deposits
.Where(d => !d.IsDeleted && d.DepositAccountId != null && d.ReceivedDate <= asOfEnd)
.GroupBy(d => d.DepositAccountId!.Value)
.Select(g => new { Id = g.Key, Amount = g.Sum(d => d.Amount) })
.ToDictionaryAsync(g => g.Id, g => g.Amount);
// Customer Deposits liability (2300): credits = all deposits taken; debits = deposits applied to invoices.
var custDepositsAcctIdBs = await _context.Accounts
.Where(a => a.CompanyId == companyId && a.AccountNumber == "2300" && a.IsActive && !a.IsDeleted)
.Select(a => (int?)a.Id).FirstOrDefaultAsync();
var custDepositsCreditsBs = custDepositsAcctIdBs.HasValue
? (await _context.Deposits
.Where(d => !d.IsDeleted && d.ReceivedDate <= asOfEnd)
.SumAsync(d => (decimal?)d.Amount) ?? 0m) : 0m;
var custDepositsDebitsBs = custDepositsAcctIdBs.HasValue
? (await _context.Deposits
.Where(d => !d.IsDeleted && d.AppliedToInvoiceId != null && d.AppliedDate <= asOfEnd)
.SumAsync(d => (decimal?)d.Amount) ?? 0m) : 0m;
// Gift Certificate Liability (2500): balance driven by GC issuances, redemptions, and voids.
var gcLiabilityAcctIdBs = await _context.Accounts
.Where(a => a.CompanyId == companyId && a.AccountNumber == "2500" && a.IsActive && !a.IsDeleted)
.Select(a => (int?)a.Id).FirstOrDefaultAsync();
var gcLiabilityCreditsBs = gcLiabilityAcctIdBs.HasValue
? (await _context.GiftCertificates
.Where(gc => !gc.IsDeleted && gc.IssueDate <= asOfEnd)
.SumAsync(gc => (decimal?)gc.OriginalAmount) ?? 0m) : 0m;
var gcLiabilityDebitsBs = gcLiabilityAcctIdBs.HasValue
? ((await _context.GiftCertificateRedemptions
.Where(r => !r.IsDeleted && r.RedeemedDate <= asOfEnd)
.SumAsync(r => (decimal?)r.AmountRedeemed) ?? 0m)
+ (await _context.GiftCertificates
.Where(gc => !gc.IsDeleted && gc.Status == GiftCertificateStatus.Voided
&& gc.UpdatedAt <= asOfEnd && gc.OriginalAmount > gc.RedeemedAmount)
.SumAsync(gc => (decimal?)(gc.OriginalAmount - gc.RedeemedAmount)) ?? 0m)) : 0m;
// Retained earnings = net P&L from inception through asOf, covering four sources:
// (1) invoice revenue, (2) invoice discounts, (3) direct expenses, (4) vendor bill costs,
// plus (5) the net effect of any posted journal entries on revenue/expense/COGS accounts
// (accruals, depreciation, year-end closes, and other adjustments not in the tables above).
var lifetimeRevenue = await _context.InvoiceItems var lifetimeRevenue = await _context.InvoiceItems
.Where(ii => ii.Invoice.Status != InvoiceStatus.Draft && ii.Invoice.Status != InvoiceStatus.Voided && ii.Invoice.InvoiceDate <= asOfEnd) .Where(ii => ii.Invoice.Status != InvoiceStatus.Draft && ii.Invoice.Status != InvoiceStatus.Voided && ii.Invoice.InvoiceDate <= asOfEnd)
.SumAsync(ii => (decimal?)ii.TotalPrice) ?? 0; .SumAsync(ii => (decimal?)ii.TotalPrice) ?? 0;
var lifetimeCogs = await _context.Expenses var lifetimeDiscounts = isCash ? 0m
: (await _context.Invoices
.Where(i => i.Status != InvoiceStatus.Draft && i.Status != InvoiceStatus.Voided
&& i.DiscountAmount > 0 && i.InvoiceDate <= asOfEnd)
.SumAsync(i => (decimal?)i.DiscountAmount) ?? 0m);
// Credit memos applied to invoices reduce net revenue (contra-revenue, same as discounts).
var lifetimeCreditMemos = isCash ? 0m
: (await _context.CreditMemoApplications
.Where(a => a.AppliedDate <= asOfEnd && a.Invoice.Status != InvoiceStatus.Voided)
.SumAsync(a => (decimal?)a.AmountApplied) ?? 0m);
var lifetimeDirectExp = await _context.Expenses
.Where(e => e.Date <= asOfEnd) .Where(e => e.Date <= asOfEnd)
.SumAsync(e => (decimal?)e.Amount) ?? 0; .SumAsync(e => (decimal?)e.Amount) ?? 0;
var lifetimeBillCosts = await _context.BillLineItems var lifetimeBillCosts = await _context.BillLineItems
.Where(bli => bli.Bill.Status != BillStatus.Draft && bli.Bill.Status != BillStatus.Voided && bli.Bill.BillDate <= asOfEnd) .Where(bli => bli.Bill.Status != BillStatus.Draft && bli.Bill.Status != BillStatus.Voided && bli.Bill.BillDate <= asOfEnd)
.SumAsync(bli => (decimal?)bli.Amount) ?? 0; .SumAsync(bli => (decimal?)bli.Amount) ?? 0;
var retainedEarnings = lifetimeRevenue - lifetimeCogs - lifetimeBillCosts;
// JE net effect on revenue accounts (positive = additional revenue recognised via manual JE)
var revenueAcctIds = await _context.Accounts
.Where(a => a.CompanyId == companyId && a.AccountType == AccountType.Revenue && !a.IsDeleted)
.Select(a => a.Id).ToListAsync();
var expCogsAcctIds = await _context.Accounts
.Where(a => a.CompanyId == companyId
&& (a.AccountType == AccountType.Expense || a.AccountType == AccountType.CostOfGoods)
&& !a.IsDeleted)
.Select(a => a.Id).ToListAsync();
var jeRevNet = revenueAcctIds.Count > 0
? (await _context.JournalEntryLines
.Where(l => revenueAcctIds.Contains(l.AccountId)
&& l.JournalEntry.Status == JournalEntryStatus.Posted
&& l.JournalEntry.EntryDate <= asOfEnd)
.SumAsync(l => (decimal?)(l.CreditAmount - l.DebitAmount)) ?? 0m)
: 0m;
// JE net effect on expense/COGS accounts (positive = additional expense recognised via manual JE)
var jeExpNet = expCogsAcctIds.Count > 0
? (await _context.JournalEntryLines
.Where(l => expCogsAcctIds.Contains(l.AccountId)
&& l.JournalEntry.Status == JournalEntryStatus.Posted
&& l.JournalEntry.EntryDate <= asOfEnd)
.SumAsync(l => (decimal?)(l.DebitAmount - l.CreditAmount)) ?? 0m)
: 0m;
// GC items sold via invoices are reclassified to GC Liability and not yet earned income.
var lifetimeGcReclassified = await _context.InvoiceItems
.Where(ii => ii.IsGiftCertificate
&& ii.Invoice.Status != InvoiceStatus.Draft
&& ii.Invoice.Status != InvoiceStatus.Voided
&& ii.Invoice.InvoiceDate <= asOfEnd)
.SumAsync(ii => (decimal?)ii.TotalPrice) ?? 0m;
// Voided GCs with remaining balance become breakage income (the liability is extinguished).
var lifetimeGcBreakage = await _context.GiftCertificates
.Where(gc => !gc.IsDeleted && gc.Status == GiftCertificateStatus.Voided
&& gc.UpdatedAt <= asOfEnd && gc.OriginalAmount > gc.RedeemedAmount)
.SumAsync(gc => (decimal?)(gc.OriginalAmount - gc.RedeemedAmount)) ?? 0m;
var retainedEarnings = lifetimeRevenue + jeRevNet
- lifetimeDiscounts
- lifetimeCreditMemos
- lifetimeGcReclassified // deferred to GC Liability, not earned yet
+ lifetimeGcBreakage // breakage income when GC voided with balance
- lifetimeDirectExp
- lifetimeBillCosts
- jeExpNet;
var accounts = await _context.Accounts var accounts = await _context.Accounts
.Where(a => a.IsActive) .Where(a => a.IsActive)
@@ -248,6 +415,7 @@ public class FinancialReportService : IFinancialReportService
{ {
credits = billsByApAcct.GetValueOrDefault(a.Id); credits = billsByApAcct.GetValueOrDefault(a.Id);
debits = bpByApAcct.GetValueOrDefault(a.Id); debits = bpByApAcct.GetValueOrDefault(a.Id);
debits += vcByApAcctBs.GetValueOrDefault(a.Id); // vendor credit applications reduce AP
} }
else else
{ {
@@ -255,6 +423,18 @@ public class FinancialReportService : IFinancialReportService
credits += expFromByAcct.GetValueOrDefault(a.Id); credits += expFromByAcct.GetValueOrDefault(a.Id);
credits += bpFromByAcct.GetValueOrDefault(a.Id); credits += bpFromByAcct.GetValueOrDefault(a.Id);
credits += taxByAcct.GetValueOrDefault(a.Id); credits += taxByAcct.GetValueOrDefault(a.Id);
credits += refundsByAcctBs.GetValueOrDefault(a.Id); // refunds reduce bank balance
debits += depositsByAcctDepBs.GetValueOrDefault(a.Id); // deposits increase bank balance
if (gcLiabilityAcctIdBs.HasValue && a.Id == gcLiabilityAcctIdBs.Value)
{
credits += gcLiabilityCreditsBs; // GC issued → CR liability
debits += gcLiabilityDebitsBs; // redeemed/voided → DR liability
}
if (custDepositsAcctIdBs.HasValue && a.Id == custDepositsAcctIdBs.Value)
{
credits += custDepositsCreditsBs; // deposits taken → CR liability
debits += custDepositsDebitsBs; // deposits applied → DR liability
}
} }
decimal opening = (a.OpeningBalanceDate == null || a.OpeningBalanceDate.Value.Date <= asOf) decimal opening = (a.OpeningBalanceDate == null || a.OpeningBalanceDate.Value.Date <= asOf)
@@ -652,20 +832,277 @@ public class FinancialReportService : IFinancialReportService
} }
/// <inheritdoc/> /// <inheritdoc/>
/// <remarks>
/// Balances are computed dynamically from transaction tables using the same pre-computed
/// dictionary approach as <see cref="GetBalanceSheetAsync"/>, so the <paramref name="asOf"/>
/// date is respected. This replaces the previous implementation that read the denormalised
/// <c>Account.CurrentBalance</c> field, which always reflected the current date regardless of
/// what date was selected.
/// </remarks>
public async Task<TrialBalanceDto> GetTrialBalanceAsync(int companyId, DateTime asOf) public async Task<TrialBalanceDto> GetTrialBalanceAsync(int companyId, DateTime asOf)
{ {
var asOfEnd = asOf.AddDays(1).AddTicks(-1);
var companyName = await GetCompanyNameAsync(companyId); var companyName = await GetCompanyNameAsync(companyId);
// ── Pre-compute per-account contribution dictionaries (batch GROUP BY, no N+1) ──────
// Bank/cash: customer payments deposited here (DR)
var depositsByAcct = await _context.Payments
.Where(p => p.PaymentDate <= asOfEnd && p.DepositAccountId != null
&& p.Invoice.Status != InvoiceStatus.Voided
&& p.Invoice.Status != InvoiceStatus.WrittenOff)
.GroupBy(p => p.DepositAccountId!.Value)
.Select(g => new { Id = g.Key, Amt = g.Sum(p => p.Amount) })
.ToDictionaryAsync(g => g.Id, g => g.Amt);
// AP: vendor credit applications reduce AP (DR) — credits are applied when a vendor
// issues a credit note and it is matched against a specific bill.
var vcByApAcct = await _context.VendorCreditApplications
.Where(vca => vca.AppliedDate <= asOfEnd)
.GroupBy(vca => vca.VendorCredit.APAccountId)
.Select(g => new { Id = g.Key, Amt = g.Sum(vca => vca.Amount) })
.ToDictionaryAsync(g => g.Id, g => g.Amt);
// Bank/cash: expenses paid from here (CR)
var expFromByAcct = await _context.Expenses
.Where(e => e.Date <= asOfEnd)
.GroupBy(e => e.PaymentAccountId)
.Select(g => new { Id = g.Key, Amt = g.Sum(e => e.Amount) })
.ToDictionaryAsync(g => g.Id, g => g.Amt);
// Bank/cash: bill payments made from here (CR)
var bpFromByAcct = await _context.BillPayments
.Where(bp => bp.PaymentDate <= asOfEnd)
.GroupBy(bp => bp.BankAccountId)
.Select(g => new { Id = g.Key, Amt = g.Sum(bp => bp.Amount) })
.ToDictionaryAsync(g => g.Id, g => g.Amt);
// AP: bills increase AP (CR)
var billsByApAcct = await _context.Bills
.Where(b => b.Status != BillStatus.Draft && b.Status != BillStatus.Voided && b.BillDate <= asOfEnd)
.GroupBy(b => b.APAccountId)
.Select(g => new { Id = g.Key, Amt = g.Sum(b => b.Total) })
.ToDictionaryAsync(g => g.Id, g => g.Amt);
// AP: bill payments reduce AP (DR)
var bpByApAcct = await _context.BillPayments
.Where(bp => bp.PaymentDate <= asOfEnd)
.GroupBy(bp => bp.Bill.APAccountId)
.Select(g => new { Id = g.Key, Amt = g.Sum(bp => bp.Amount) })
.ToDictionaryAsync(g => g.Id, g => g.Amt);
// Tax liability: sales tax collected (CR)
var taxByAcct = await _context.Invoices
.Where(i => i.SalesTaxAccountId != null && i.TaxAmount > 0
&& i.Status != InvoiceStatus.Draft && i.Status != InvoiceStatus.Voided
&& i.InvoiceDate <= asOfEnd)
.GroupBy(i => i.SalesTaxAccountId!.Value)
.Select(g => new { Id = g.Key, Amt = g.Sum(i => i.TaxAmount) })
.ToDictionaryAsync(g => g.Id, g => g.Amt);
// Revenue accounts: invoice line items (CR)
var revenueByAcct = await _context.InvoiceItems
.Where(ii => ii.RevenueAccountId != null
&& ii.Invoice.Status != InvoiceStatus.Draft
&& ii.Invoice.Status != InvoiceStatus.Voided
&& ii.Invoice.InvoiceDate <= asOfEnd)
.GroupBy(ii => ii.RevenueAccountId!.Value)
.Select(g => new { Id = g.Key, Amt = g.Sum(ii => ii.TotalPrice) })
.ToDictionaryAsync(g => g.Id, g => g.Amt);
// Expense accounts: direct expenses (DR)
var expenseByAcct = await _context.Expenses
.Where(e => e.Date <= asOfEnd)
.GroupBy(e => e.ExpenseAccountId)
.Select(g => new { Id = g.Key, Amt = g.Sum(e => e.Amount) })
.ToDictionaryAsync(g => g.Id, g => g.Amt);
// Expense/COGS accounts: vendor bill line items (DR)
var billLinesByAcct = await _context.BillLineItems
.Where(bli => bli.AccountId != null
&& bli.Bill.Status != BillStatus.Draft
&& bli.Bill.Status != BillStatus.Voided
&& bli.Bill.BillDate <= asOfEnd)
.GroupBy(bli => bli.AccountId!.Value)
.Select(g => new { Id = g.Key, Amt = g.Sum(bli => bli.Amount) })
.ToDictionaryAsync(g => g.Id, g => g.Amt);
// Sales Discounts contra-revenue account: invoice discounts and credit memo applications (DR).
// Both reduce net revenue and are attributed to account 4950 as contra-revenue debits.
// Credit memo applications are also added to AR credits below so the double-entry balances.
var discountAcctId = await _context.Accounts
.Where(a => a.CompanyId == companyId && a.AccountNumber == "4950" && a.IsActive && !a.IsDeleted)
.Select(a => (int?)a.Id)
.FirstOrDefaultAsync();
discountAcctId ??= await _context.Accounts
.Where(a => a.CompanyId == companyId && a.AccountType == AccountType.Revenue
&& a.IsActive && !a.IsDeleted && a.Name.ToLower().Contains("discount"))
.Select(a => (int?)a.Id)
.FirstOrDefaultAsync();
var cmApplied = await _context.CreditMemoApplications
.Where(a => a.AppliedDate <= asOfEnd
&& a.Invoice.Status != InvoiceStatus.Voided)
.SumAsync(a => (decimal?)a.AmountApplied) ?? 0m;
var discountsByAcct = new Dictionary<int, decimal>();
if (discountAcctId.HasValue)
{
var totalDiscounts = await _context.Invoices
.Where(i => i.DiscountAmount > 0
&& i.Status != InvoiceStatus.Draft
&& i.Status != InvoiceStatus.Voided
&& i.InvoiceDate <= asOfEnd)
.SumAsync(i => (decimal?)i.DiscountAmount) ?? 0m;
if (totalDiscounts + cmApplied > 0)
discountsByAcct[discountAcctId.Value] = totalDiscounts + cmApplied;
}
// JE lines: posted entries debit/credit all account types
var jeDebitsByAcct = await _context.JournalEntryLines
.Where(l => l.JournalEntry.Status == JournalEntryStatus.Posted
&& l.JournalEntry.EntryDate <= asOfEnd)
.GroupBy(l => l.AccountId)
.Select(g => new { Id = g.Key, Amt = g.Sum(l => l.DebitAmount) })
.ToDictionaryAsync(g => g.Id, g => g.Amt);
var jeCreditsByAcct = await _context.JournalEntryLines
.Where(l => l.JournalEntry.Status == JournalEntryStatus.Posted
&& l.JournalEntry.EntryDate <= asOfEnd)
.GroupBy(l => l.AccountId)
.Select(g => new { Id = g.Key, Amt = g.Sum(l => l.CreditAmount) })
.ToDictionaryAsync(g => g.Id, g => g.Amt);
// AR totals (single AR account assumed per standard small-business chart of accounts).
// Credits include both cash payments and credit memo applications (which reduce open AR
// when a customer credit is applied against a specific invoice).
var arTotalDebits = await _context.Invoices
.Where(i => i.Status != InvoiceStatus.Draft && i.Status != InvoiceStatus.Voided
&& i.InvoiceDate <= asOfEnd)
.SumAsync(i => (decimal?)i.Total) ?? 0m;
var arTotalCredits = await _context.Payments
.Where(p => p.PaymentDate <= asOfEnd
&& p.Invoice.Status != InvoiceStatus.Voided
&& p.Invoice.Status != InvoiceStatus.WrittenOff)
.SumAsync(p => (decimal?)p.Amount) ?? 0m;
arTotalCredits += cmApplied; // credit memo applications reduce AR balance
// Refunds reverse collected payments — reduce net AR credits (re-opens the receivable).
var refundTotal = await _context.Refunds
.Where(r => r.RefundDate <= asOfEnd && !r.IsDeleted)
.SumAsync(r => (decimal?)r.Amount) ?? 0m;
arTotalCredits -= refundTotal;
// Refunds by bank account: money leaving the account (CR to checking/bank).
var refundsByAcct = await _context.Refunds
.Where(r => r.RefundDate <= asOfEnd && !r.IsDeleted && r.DepositAccountId != null)
.GroupBy(r => r.DepositAccountId!.Value)
.Select(g => new { Id = g.Key, Amt = g.Sum(r => r.Amount) })
.ToDictionaryAsync(g => g.Id, g => g.Amt);
// Deposits by bank account: cash received at deposit recording time (DR bank).
// Deposit-sourced Payments have DepositAccountId = null, so there is no double-count with depositsByAcct.
var depositsByAcctDep = await _context.Deposits
.Where(d => !d.IsDeleted && d.DepositAccountId != null && d.ReceivedDate <= asOfEnd)
.GroupBy(d => d.DepositAccountId!.Value)
.Select(g => new { Id = g.Key, Amt = g.Sum(d => d.Amount) })
.ToDictionaryAsync(g => g.Id, g => g.Amt);
// Customer Deposits liability (2300): credits = all deposits taken; debits = deposits applied to invoices.
var custDepositsAcctId = await _context.Accounts
.Where(a => a.CompanyId == companyId && a.AccountNumber == "2300" && a.IsActive && !a.IsDeleted)
.Select(a => (int?)a.Id).FirstOrDefaultAsync();
var custDepositsCredits = custDepositsAcctId.HasValue
? (await _context.Deposits
.Where(d => !d.IsDeleted && d.ReceivedDate <= asOfEnd)
.SumAsync(d => (decimal?)d.Amount) ?? 0m) : 0m;
var custDepositsDebits = custDepositsAcctId.HasValue
? (await _context.Deposits
.Where(d => !d.IsDeleted && d.AppliedToInvoiceId != null && d.AppliedDate <= asOfEnd)
.SumAsync(d => (decimal?)d.Amount) ?? 0m) : 0m;
// Gift Certificate Liability (2500): balance driven by GC issuances, redemptions, and voids.
var gcLiabilityAcctId = await _context.Accounts
.Where(a => a.CompanyId == companyId && a.AccountNumber == "2500" && a.IsActive && !a.IsDeleted)
.Select(a => (int?)a.Id).FirstOrDefaultAsync();
var gcLiabilityCredits = gcLiabilityAcctId.HasValue
? (await _context.GiftCertificates
.Where(gc => !gc.IsDeleted && gc.IssueDate <= asOfEnd)
.SumAsync(gc => (decimal?)gc.OriginalAmount) ?? 0m) : 0m;
var gcLiabilityDebits = gcLiabilityAcctId.HasValue
? ((await _context.GiftCertificateRedemptions
.Where(r => !r.IsDeleted && r.RedeemedDate <= asOfEnd)
.SumAsync(r => (decimal?)r.AmountRedeemed) ?? 0m)
+ (await _context.GiftCertificates
.Where(gc => !gc.IsDeleted && gc.Status == GiftCertificateStatus.Voided
&& gc.UpdatedAt <= asOfEnd && gc.OriginalAmount > gc.RedeemedAmount)
.SumAsync(gc => (decimal?)(gc.OriginalAmount - gc.RedeemedAmount)) ?? 0m)) : 0m;
// ── Per-account balance computation ─────────────────────────────────────────────────
var accounts = await _context.Accounts var accounts = await _context.Accounts
.Where(a => a.CompanyId == companyId && a.IsActive) .Where(a => a.CompanyId == companyId && a.IsActive)
.OrderBy(a => a.AccountNumber) .OrderBy(a => a.AccountNumber)
.ToListAsync(); .ToListAsync();
var lines = new List<TrialBalanceLine>(); decimal ComputeAsOfBalance(Account a)
{
bool isDebitNormal = AccountingRules.IsNormalDebitBalance(a.AccountSubType);
decimal debits = 0m, credits = 0m;
if (a.AccountSubType == AccountSubType.AccountsReceivable)
{
debits = arTotalDebits;
credits = arTotalCredits;
}
else if (a.AccountSubType == AccountSubType.AccountsPayable)
{
credits = billsByApAcct.GetValueOrDefault(a.Id);
debits = bpByApAcct.GetValueOrDefault(a.Id);
debits += vcByApAcct.GetValueOrDefault(a.Id); // vendor credit applications reduce AP
}
else
{
// All other accounts: sum contributions from each transaction source that can
// post to this account. Dictionaries only contain entries for relevant account IDs,
// so GetValueOrDefault returns 0 for sources that do not apply to this account type.
debits += depositsByAcct.GetValueOrDefault(a.Id);
credits += expFromByAcct.GetValueOrDefault(a.Id);
credits += bpFromByAcct.GetValueOrDefault(a.Id);
credits += taxByAcct.GetValueOrDefault(a.Id);
credits += revenueByAcct.GetValueOrDefault(a.Id);
debits += expenseByAcct.GetValueOrDefault(a.Id);
debits += billLinesByAcct.GetValueOrDefault(a.Id);
debits += discountsByAcct.GetValueOrDefault(a.Id);
credits += refundsByAcct.GetValueOrDefault(a.Id); // refunds reduce bank balance
debits += depositsByAcctDep.GetValueOrDefault(a.Id); // deposits increase bank balance
if (gcLiabilityAcctId.HasValue && a.Id == gcLiabilityAcctId.Value)
{
credits += gcLiabilityCredits; // GC issued → CR liability
debits += gcLiabilityDebits; // redeemed/voided → DR liability
}
if (custDepositsAcctId.HasValue && a.Id == custDepositsAcctId.Value)
{
credits += custDepositsCredits; // deposits taken → CR liability
debits += custDepositsDebits; // deposits applied → DR liability
}
}
// Manual JEs apply to all account types (including AR/AP for unusual adjustments)
debits += jeDebitsByAcct.GetValueOrDefault(a.Id);
credits += jeCreditsByAcct.GetValueOrDefault(a.Id);
decimal opening = (a.OpeningBalanceDate == null || a.OpeningBalanceDate.Value.Date <= asOf)
? a.OpeningBalance : 0m;
decimal net = isDebitNormal ? debits - credits : credits - debits;
return opening + net;
}
var lines = new List<TrialBalanceLine>();
foreach (var acct in accounts) foreach (var acct in accounts)
{ {
if (acct.CurrentBalance == 0) continue; var balance = ComputeAsOfBalance(acct);
if (balance == 0m) continue;
var isDebitNormal = AccountingRules.IsNormalDebitBalance(acct.AccountSubType); var isDebitNormal = AccountingRules.IsNormalDebitBalance(acct.AccountSubType);
var line = new TrialBalanceLine var line = new TrialBalanceLine
@@ -679,14 +1116,14 @@ public class FinancialReportService : IFinancialReportService
if (isDebitNormal) if (isDebitNormal)
{ {
// Normal debit: positive balance → Debit column; negative → Credit column (abnormal) // Normal debit: positive balance → Debit column; negative → Credit column (abnormal)
if (acct.CurrentBalance >= 0) line.DebitBalance = acct.CurrentBalance; if (balance >= 0m) line.DebitBalance = balance;
else line.CreditBalance = -acct.CurrentBalance; else line.CreditBalance = -balance;
} }
else else
{ {
// Normal credit: positive balance → Credit column; negative → Debit column (abnormal) // Normal credit: positive balance → Credit column; negative → Debit column (abnormal)
if (acct.CurrentBalance >= 0) line.CreditBalance = acct.CurrentBalance; if (balance >= 0m) line.CreditBalance = balance;
else line.DebitBalance = -acct.CurrentBalance; else line.DebitBalance = -balance;
} }
lines.Add(line); lines.Add(line);
@@ -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,6 +375,102 @@ 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 ────────────────── // ── 10. Journal Entry lines touching this account ──────────────────
@@ -382,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)
@@ -434,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
@@ -449,6 +642,36 @@ 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) // 10. Posted journal entry lines touching this account (prior to period)
@@ -621,7 +621,7 @@ public class NotificationService : INotificationService
/// (the <paramref name="paymentUrl"/> parameter). Without a payment URL the email is a /// (the <paramref name="paymentUrl"/> parameter). Without a payment URL the email is a
/// standard "here is your invoice" message with no payment CTA. /// standard "here is your invoice" message with no payment CTA.
/// </summary> /// </summary>
public async Task NotifyInvoiceSentAsync(Invoice invoice, byte[]? pdfAttachment = null, string? pdfFilename = null, string? paymentUrl = null, string? overrideEmail = null) public async Task NotifyInvoiceSentAsync(Invoice invoice, byte[]? pdfAttachment = null, string? pdfFilename = null, string? paymentUrl = null, string? overrideEmail = null, bool sendSms = false, string? viewUrl = null)
{ {
try try
{ {
@@ -705,6 +705,50 @@ public class NotificationService : INotificationService
await WriteLog(SkippedLog(NotificationChannel.Email, NotificationType.InvoiceSent, await WriteLog(SkippedLog(NotificationChannel.Email, NotificationType.InvoiceSent,
customerName, string.Join(", ", invoiceEmails), invoice.CompanyId, customerId: customer.Id, invoiceId: invoice.Id)); customerName, string.Join(", ", invoiceEmails), invoice.CompanyId, customerId: customer.Id, invoiceId: invoice.Id));
} }
// SMS — only when explicitly requested by staff (sendSms=true), customer has opted in,
// and the company's SMS is active. Uses viewUrl (permanent) so customer can see the full
// invoice; paymentUrl (expiring Stripe link) is surfaced on the view page itself.
if (sendSms)
{
var smsAllowed = await IsSmsAllowedForCompanyAsync(company);
var smsPhone = customer.MobilePhone ?? customer.Phone;
if (smsAllowed && customer.NotifyBySms && !string.IsNullOrWhiteSpace(smsPhone))
{
var urlForSms = viewUrl ?? paymentUrl ?? string.Empty;
var values = new Dictionary<string, string>
{
["companyName"] = companyName,
["invoiceNumber"] = invoice.InvoiceNumber,
["invoiceTotal"] = invoice.Total.ToString("C"),
["viewUrl"] = urlForSms
};
var message = await GetRenderedSmsAsync(invoice.CompanyId, NotificationType.InvoiceSent, values,
$"{companyName}: Invoice {invoice.InvoiceNumber} for {invoice.Total:C} is ready. View your invoice: {urlForSms} Reply STOP to opt out.");
var (smsSent, smsError) = await _smsService.SendSmsAsync(smsPhone, message);
await WriteLog(new NotificationLog
{
Channel = NotificationChannel.Sms,
NotificationType = NotificationType.InvoiceSent,
Status = smsSent ? NotificationStatus.Sent : NotificationStatus.Failed,
RecipientName = customerName,
Recipient = smsPhone,
Message = message,
ErrorMessage = smsError,
SentAt = DateTime.UtcNow,
CustomerId = customer.Id,
InvoiceId = invoice.Id,
CompanyId = invoice.CompanyId
});
}
else if (!string.IsNullOrWhiteSpace(smsPhone))
{
await WriteLog(SkippedLog(NotificationChannel.Sms, NotificationType.InvoiceSent,
customerName, smsPhone, invoice.CompanyId, customerId: customer.Id, invoiceId: invoice.Id));
}
}
} }
catch (Exception ex) catch (Exception ex)
{ {
@@ -1153,6 +1197,10 @@ public class NotificationService : INotificationService
"Invoice {{invoiceNumber}} from {{companyName}}", "Invoice {{invoiceNumber}} from {{companyName}}",
"<p>Dear {{customerName}},</p><p>Please find your invoice <strong>{{invoiceNumber}}</strong> for <strong>{{invoiceTotal}}</strong> attached.{{invoiceDueDate}}</p><p>Thank you for your business with {{companyName}}.</p>" "<p>Dear {{customerName}},</p><p>Please find your invoice <strong>{{invoiceNumber}}</strong> for <strong>{{invoiceTotal}}</strong> attached.{{invoiceDueDate}}</p><p>Thank you for your business with {{companyName}}.</p>"
), ),
[(NotificationType.InvoiceSent, NotificationChannel.Sms)] = (
null,
"{{companyName}}: Invoice {{invoiceNumber}} for {{invoiceTotal}} is ready. View your invoice: {{viewUrl}} Reply STOP to opt out."
),
[(NotificationType.PaymentReceived, NotificationChannel.Email)] = ( [(NotificationType.PaymentReceived, NotificationChannel.Email)] = (
"Payment Received — Invoice {{invoiceNumber}}", "Payment Received — Invoice {{invoiceNumber}}",
"<p>Dear {{customerName}},</p><p>We have received your payment of <strong>{{paymentAmount}}</strong> on {{paymentDate}} for invoice <strong>{{invoiceNumber}}</strong>.{{balanceDue}}</p><p>Thank you for your business with {{companyName}}.</p>" "<p>Dear {{customerName}},</p><p>We have received your payment of <strong>{{paymentAmount}}</strong> on {{paymentDate}} for invoice <strong>{{invoiceNumber}}</strong>.{{balanceDue}}</p><p>Thank you for your business with {{companyName}}.</p>"
@@ -70,6 +70,10 @@ public partial class SeedDataService
new Account { AccountNumber = "4100", Name = "Sandblasting Revenue", AccountType = AccountType.Revenue, AccountSubType = AccountSubType.ServiceRevenue, IsSystem = false, IsActive = true, Description = "Revenue from sandblasting services", CompanyId = company.Id, CreatedAt = now }, new Account { AccountNumber = "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;
} }
@@ -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;
}
@@ -543,6 +543,15 @@ public class CompanySettingsController : Controller
public Task<IActionResult> UpdateWorkOrderTemplate([FromBody] UpdateWorkOrderTemplateDto dto) => public Task<IActionResult> UpdateWorkOrderTemplate([FromBody] UpdateWorkOrderTemplateDto dto) =>
UpdatePreferences(dto, "Work order settings saved successfully."); UpdatePreferences(dto, "Work order settings saved successfully.");
/// <summary>
/// Saves kiosk intake output preference ("Quote" or "Job") to <see cref="CompanyPreferences"/>.
/// Delegates to <see cref="UpdatePreferences{TDto}"/>.
/// </summary>
// POST: CompanySettings/UpdateKioskSettings
[HttpPost]
public Task<IActionResult> UpdateKioskSettings([FromBody] UpdateKioskSettingsDto dto) =>
UpdatePreferences(dto, "Kiosk settings saved successfully.");
/// <summary> /// <summary>
/// Persists the company's pricing model parameters — labor rates, sandblasting/masking multipliers, /// Persists the company's pricing model parameters — labor rates, sandblasting/masking multipliers,
/// oven cost per hour, overhead admin/facility percentages, profit margin, and default tax rate — /// oven cost per hour, overhead admin/facility percentages, profit margin, and default tax rate —
@@ -2685,6 +2694,7 @@ public class CompanySettingsController : Controller
{ {
list.Add(("{{invoiceTotal}}", "Invoice total amount (formatted as currency)")); list.Add(("{{invoiceTotal}}", "Invoice total amount (formatted as currency)"));
list.Add(("{{invoiceDueDate}}", "Due date phrase, e.g. \" Due by January 1, 2026.\" — blank if no due date is set")); list.Add(("{{invoiceDueDate}}", "Due date phrase, e.g. \" Due by January 1, 2026.\" — blank if no due date is set"));
list.Add(("{{viewUrl}}", "Permanent link for the customer to view the invoice online (used in SMS)"));
} }
if (type == NotificationType.PaymentReceived) if (type == NotificationType.PaymentReceived)
@@ -2,6 +2,7 @@ using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Rendering; using Microsoft.AspNetCore.Mvc.Rendering;
using PowderCoating.Application.Interfaces;
using PowderCoating.Core.Entities; using PowderCoating.Core.Entities;
using PowderCoating.Core.Enums; using PowderCoating.Core.Enums;
using PowderCoating.Core.Interfaces; using PowderCoating.Core.Interfaces;
@@ -15,6 +16,9 @@ namespace PowderCoating.Web.Controllers;
/// balance and can be issued standalone (goodwill, billing correction) or linked to an original /// 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 /// invoice (price dispute, rework resolution). Applied portions reduce invoice BalanceDue and
/// customer.CreditBalance atomically inside a transaction. /// 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> /// </summary>
[Authorize(Policy = AppConstants.Policies.CompanyAdminOnly)] [Authorize(Policy = AppConstants.Policies.CompanyAdminOnly)]
public class CreditMemosController : Controller public class CreditMemosController : Controller
@@ -23,17 +27,20 @@ public class CreditMemosController : Controller
private readonly ITenantContext _tenantContext; private readonly ITenantContext _tenantContext;
private readonly UserManager<ApplicationUser> _userManager; private readonly UserManager<ApplicationUser> _userManager;
private readonly ILogger<CreditMemosController> _logger; private readonly ILogger<CreditMemosController> _logger;
private readonly IAccountBalanceService _accountBalanceService;
public CreditMemosController( public CreditMemosController(
IUnitOfWork unitOfWork, IUnitOfWork unitOfWork,
ITenantContext tenantContext, ITenantContext tenantContext,
UserManager<ApplicationUser> userManager, UserManager<ApplicationUser> userManager,
ILogger<CreditMemosController> logger) ILogger<CreditMemosController> logger,
IAccountBalanceService accountBalanceService)
{ {
_unitOfWork = unitOfWork; _unitOfWork = unitOfWork;
_tenantContext = tenantContext; _tenantContext = tenantContext;
_userManager = userManager; _userManager = userManager;
_logger = logger; _logger = logger;
_accountBalanceService = accountBalanceService;
} }
/// <summary>Lists all credit memos for the current company with optional status and text filters.</summary> /// <summary>Lists all credit memos for the current company with optional status and text filters.</summary>
@@ -245,6 +252,20 @@ public class CreditMemosController : Controller
await _unitOfWork.Invoices.UpdateAsync(invoice); 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(); await _unitOfWork.CompleteAsync();
}); });
@@ -368,6 +368,9 @@ public class DashboardController : Controller
ViewBag.GuidedActivationBanner = BuildGuidedActivationBanner(companyPrefs); ViewBag.GuidedActivationBanner = BuildGuidedActivationBanner(companyPrefs);
ViewBag.ShopProgressWidget = await BuildShopProgressWidgetAsync(currentCompanyId.Value, companyPrefs); ViewBag.ShopProgressWidget = await BuildShopProgressWidgetAsync(currentCompanyId.Value, companyPrefs);
var companyForKiosk = await _unitOfWork.Companies.GetByIdAsync(currentCompanyId.Value);
ViewBag.KioskActivated = !string.IsNullOrEmpty(companyForKiosk?.KioskActivationToken);
} }
return View(vm); return View(vm);
@@ -12,6 +12,7 @@ using PowderCoating.Shared.Constants;
using QuestPDF.Fluent; using QuestPDF.Fluent;
using QuestPDF.Helpers; using QuestPDF.Helpers;
using QuestPDF.Infrastructure; using QuestPDF.Infrastructure;
using AccountSubTypeEnum = PowderCoating.Core.Enums.AccountSubType;
namespace PowderCoating.Web.Controllers; namespace PowderCoating.Web.Controllers;
@@ -22,17 +23,20 @@ public class DepositsController : Controller
private readonly UserManager<ApplicationUser> _userManager; private readonly UserManager<ApplicationUser> _userManager;
private readonly ILogger<DepositsController> _logger; private readonly ILogger<DepositsController> _logger;
private readonly ICompanyLogoService _logoService; private readonly ICompanyLogoService _logoService;
private readonly IAccountBalanceService _accountBalanceService;
public DepositsController( public DepositsController(
IUnitOfWork unitOfWork, IUnitOfWork unitOfWork,
UserManager<ApplicationUser> userManager, UserManager<ApplicationUser> userManager,
ILogger<DepositsController> logger, ILogger<DepositsController> logger,
ICompanyLogoService logoService) ICompanyLogoService logoService,
IAccountBalanceService accountBalanceService)
{ {
_unitOfWork = unitOfWork; _unitOfWork = unitOfWork;
_userManager = userManager; _userManager = userManager;
_logger = logger; _logger = logger;
_logoService = logoService; _logoService = logoService;
_accountBalanceService = accountBalanceService;
} }
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------
@@ -76,6 +80,7 @@ public class DepositsController : Controller
if (currentUser == null) return Unauthorized(); if (currentUser == null) return Unauthorized();
var receiptNumber = await GenerateReceiptNumberAsync(currentUser.CompanyId); var receiptNumber = await GenerateReceiptNumberAsync(currentUser.CompanyId);
var checkingAcctId = await GetCheckingAccountIdAsync(currentUser.CompanyId);
var deposit = new Deposit var deposit = new Deposit
{ {
@@ -88,6 +93,7 @@ public class DepositsController : Controller
ReceivedDate = receivedDate, ReceivedDate = receivedDate,
Reference = reference, Reference = reference,
Notes = notes, Notes = notes,
DepositAccountId = checkingAcctId,
RecordedById = currentUser.Id, RecordedById = currentUser.Id,
CompanyId = currentUser.CompanyId, CompanyId = currentUser.CompanyId,
CreatedAt = DateTime.UtcNow, CreatedAt = DateTime.UtcNow,
@@ -97,6 +103,11 @@ public class DepositsController : Controller
await _unitOfWork.Deposits.AddAsync(deposit); await _unitOfWork.Deposits.AddAsync(deposit);
await _unitOfWork.CompleteAsync(); await _unitOfWork.CompleteAsync();
// GL: DR Checking (cash received) / CR Customer Deposits 2300 (liability until applied to invoice).
var custDepositsAcctId = await GetCustomerDepositsAccountIdAsync(currentUser.CompanyId);
await _accountBalanceService.DebitAsync(checkingAcctId, deposit.Amount);
await _accountBalanceService.CreditAsync(custDepositsAcctId, deposit.Amount);
return Json(new return Json(new
{ {
success = true, success = true,
@@ -137,6 +148,11 @@ public class DepositsController : Controller
if (deposit.AppliedToInvoiceId != null) if (deposit.AppliedToInvoiceId != null)
return Json(new { success = false, message = "This deposit has already been applied to an invoice and cannot be deleted." }); return Json(new { success = false, message = "This deposit has already been applied to an invoice and cannot be deleted." });
// Reverse the GL entry made at recording time: CR Checking / DR Customer Deposits 2300.
var custDepositsAcctId = await GetCustomerDepositsAccountIdAsync(deposit.CompanyId);
await _accountBalanceService.CreditAsync(deposit.DepositAccountId, deposit.Amount);
await _accountBalanceService.DebitAsync(custDepositsAcctId, deposit.Amount);
await _unitOfWork.Deposits.SoftDeleteAsync(id); await _unitOfWork.Deposits.SoftDeleteAsync(id);
await _unitOfWork.CompleteAsync(); await _unitOfWork.CompleteAsync();
@@ -419,6 +435,24 @@ public class DepositsController : Controller
return hex.StartsWith("#") ? hex : fallback; return hex.StartsWith("#") ? hex : fallback;
} }
/// <summary>Returns the first active Checking or Cash account for the company, or null.</summary>
private async Task<int?> GetCheckingAccountIdAsync(int companyId)
{
var acct = await _unitOfWork.Accounts.FirstOrDefaultAsync(
a => a.CompanyId == companyId && a.IsActive
&& (a.AccountSubType == AccountSubTypeEnum.Checking
|| a.AccountSubType == AccountSubTypeEnum.Cash));
return acct?.Id;
}
/// <summary>Returns account 2300 "Customer Deposits" liability for the company, or null.</summary>
private async Task<int?> GetCustomerDepositsAccountIdAsync(int companyId)
{
var acct = await _unitOfWork.Accounts.FirstOrDefaultAsync(
a => a.CompanyId == companyId && a.IsActive && a.AccountNumber == "2300");
return acct?.Id;
}
private async Task<(byte[]? LogoData, string? LogoContentType)> LoadCompanyLogoAsync(Company? company) private async Task<(byte[]? LogoData, string? LogoContentType)> LoadCompanyLogoAsync(Company? company)
{ {
if (company == null) return (null, null); if (company == null) return (null, null);
@@ -1,11 +1,12 @@
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using PowderCoating.Shared.Constants;
using System.Reflection; using System.Reflection;
using System.Security.Principal; using System.Security.Principal;
namespace PowderCoating.Web.Controllers; namespace PowderCoating.Web.Controllers;
[Authorize(Roles = "SuperAdmin,Administrator")] [Authorize(Policy = AppConstants.Policies.SuperAdminOnly)]
public class DiagnosticsController : Controller public class DiagnosticsController : Controller
{ {
private readonly ILogger<DiagnosticsController> _logger; private readonly ILogger<DiagnosticsController> _logger;
@@ -10,6 +10,7 @@ using PowderCoating.Core.Enums;
using PowderCoating.Core.Interfaces; using PowderCoating.Core.Interfaces;
using PowderCoating.Shared.Constants; using PowderCoating.Shared.Constants;
using PowderCoating.Web.Helpers; using PowderCoating.Web.Helpers;
using AccountSubTypeEnum = PowderCoating.Core.Enums.AccountSubType;
namespace PowderCoating.Web.Controllers; namespace PowderCoating.Web.Controllers;
@@ -31,6 +32,7 @@ public class GiftCertificatesController : Controller
private readonly UserManager<ApplicationUser> _userManager; private readonly UserManager<ApplicationUser> _userManager;
private readonly IPdfService _pdfService; private readonly IPdfService _pdfService;
private readonly ICompanyLogoService _logoService; private readonly ICompanyLogoService _logoService;
private readonly IAccountBalanceService _accountBalanceService;
public GiftCertificatesController( public GiftCertificatesController(
IUnitOfWork unitOfWork, IUnitOfWork unitOfWork,
@@ -38,7 +40,8 @@ public class GiftCertificatesController : Controller
ILogger<GiftCertificatesController> logger, ILogger<GiftCertificatesController> logger,
UserManager<ApplicationUser> userManager, UserManager<ApplicationUser> userManager,
IPdfService pdfService, IPdfService pdfService,
ICompanyLogoService logoService) ICompanyLogoService logoService,
IAccountBalanceService accountBalanceService)
{ {
_unitOfWork = unitOfWork; _unitOfWork = unitOfWork;
_mapper = mapper; _mapper = mapper;
@@ -46,6 +49,7 @@ public class GiftCertificatesController : Controller
_userManager = userManager; _userManager = userManager;
_pdfService = pdfService; _pdfService = pdfService;
_logoService = logoService; _logoService = logoService;
_accountBalanceService = accountBalanceService;
} }
/// <summary> /// <summary>
@@ -240,6 +244,26 @@ public class GiftCertificatesController : Controller
await _unitOfWork.GiftCertificates.AddAsync(cert); await _unitOfWork.GiftCertificates.AddAsync(cert);
await _unitOfWork.CompleteAsync(); await _unitOfWork.CompleteAsync();
// GL: CR Gift Certificate Liability (2500) for the face value.
// Debit side varies by reason:
// Sold → DR Checking (received cash outside invoice flow)
// Others → DR Sales Discounts 4950 (promotional/goodwill cost)
var gcLiabilityAcctId = await GetGcLiabilityAccountIdAsync(companyId);
await _accountBalanceService.CreditAsync(gcLiabilityAcctId, cert.OriginalAmount);
if (dto.IssuedReason == GiftCertificateIssuedReason.Sold)
{
var checkingAcctId = await _unitOfWork.Accounts.FirstOrDefaultAsync(
a => a.IsActive && (a.AccountSubType == AccountSubTypeEnum.Checking
|| a.AccountSubType == AccountSubTypeEnum.Cash));
await _accountBalanceService.DebitAsync(checkingAcctId?.Id, cert.OriginalAmount);
}
else
{
var discountAcctId = await _unitOfWork.Accounts.FirstOrDefaultAsync(
a => a.IsActive && a.AccountNumber == "4950");
await _accountBalanceService.DebitAsync(discountAcctId?.Id, cert.OriginalAmount);
}
TempData["Success"] = $"Gift certificate {code} for {dto.Amount:C} created successfully."; TempData["Success"] = $"Gift certificate {code} for {dto.Amount:C} created successfully.";
return RedirectToAction(nameof(Details), new { id = cert.Id }); return RedirectToAction(nameof(Details), new { id = cert.Id });
} }
@@ -272,11 +296,24 @@ public class GiftCertificatesController : Controller
return RedirectToAction(nameof(Details), new { id }); return RedirectToAction(nameof(Details), new { id });
} }
var remaining = cert.RemainingBalance;
cert.Status = GiftCertificateStatus.Voided; cert.Status = GiftCertificateStatus.Voided;
cert.UpdatedAt = DateTime.UtcNow; cert.UpdatedAt = DateTime.UtcNow;
await _unitOfWork.GiftCertificates.UpdateAsync(cert); await _unitOfWork.GiftCertificates.UpdateAsync(cert);
await _unitOfWork.CompleteAsync(); await _unitOfWork.CompleteAsync();
// GL: DR GC Liability / CR Other Income (breakage — the company keeps the unredeemed amount)
if (remaining > 0)
{
var currentUser = await _userManager.GetUserAsync(User);
var companyId = currentUser?.CompanyId ?? 0;
var gcLiabilityAcctId = await GetGcLiabilityAccountIdAsync(companyId);
var otherIncomeAcctId = await _unitOfWork.Accounts.FirstOrDefaultAsync(
a => a.IsActive && a.AccountSubType == AccountSubTypeEnum.OtherIncome);
await _accountBalanceService.DebitAsync(gcLiabilityAcctId, remaining);
await _accountBalanceService.CreditAsync(otherIncomeAcctId?.Id, remaining);
}
TempData["Success"] = $"Gift certificate {cert.CertificateCode} has been voided."; TempData["Success"] = $"Gift certificate {cert.CertificateCode} has been voided.";
return RedirectToAction(nameof(Index)); return RedirectToAction(nameof(Index));
} }
@@ -395,6 +432,14 @@ public class GiftCertificatesController : Controller
ViewBag.Customers = list; ViewBag.Customers = list;
} }
/// <summary>Returns the Gift Certificate Liability account ID (account 2500) for the company.</summary>
private async Task<int?> GetGcLiabilityAccountIdAsync(int companyId)
{
var acct = await _unitOfWork.Accounts.FirstOrDefaultAsync(
a => a.IsActive && a.AccountNumber == "2500");
return acct?.Id;
}
private async Task<(byte[]? LogoData, string? LogoContentType)> LoadCompanyLogoAsync(Company? company) private async Task<(byte[]? LogoData, string? LogoContentType)> LoadCompanyLogoAsync(Company? company)
{ {
if (company == null) return (null, null); if (company == null) return (null, null);
@@ -125,5 +125,13 @@ namespace PowderCoating.Web.Controllers
{ {
return View(); return View();
} }
/// <summary>
/// Serves the Customer Intake Kiosk help article explaining the tablet kiosk setup, the staff-triggered intake flow, and the Intakes review page.
/// </summary>
public IActionResult CustomerIntakeKiosk()
{
return View();
}
} }
} }
@@ -677,7 +677,9 @@ public class InvoicesController : Controller
foreach (var deposit in pendingDeposits) foreach (var deposit in pendingDeposits)
{ {
// Create a Payment record for each deposit // DepositAccountId is intentionally null: the bank account was already debited
// when the deposit was recorded (DR Checking / CR Customer Deposits 2300).
// Setting it here would double-count the bank debit in the Trial Balance.
var payment = new Payment var payment = new Payment
{ {
InvoiceId = invoice.Id, InvoiceId = invoice.Id,
@@ -686,6 +688,7 @@ public class InvoicesController : Controller
PaymentMethod = deposit.PaymentMethod, PaymentMethod = deposit.PaymentMethod,
Reference = $"Deposit {deposit.ReceiptNumber}", Reference = $"Deposit {deposit.ReceiptNumber}",
Notes = deposit.Notes, Notes = deposit.Notes,
DepositAccountId = null,
RecordedById = currentUser.Id, RecordedById = currentUser.Id,
CompanyId = currentUser.CompanyId, CompanyId = currentUser.CompanyId,
CreatedAt = DateTime.UtcNow, CreatedAt = DateTime.UtcNow,
@@ -719,13 +722,31 @@ public class InvoicesController : Controller
await _unitOfWork.CompleteAsync(); await _unitOfWork.CompleteAsync();
// Update account balances: debit AR, credit revenue accounts + sales tax // Update account balances: debit AR, credit revenue accounts + sales tax.
// Discount contra-entry: DR Sales Discounts so the GL balances.
// Without it, credits (revenue + tax) exceed the AR debit by the discount amount.
var arAccountId = await GetArAccountIdAsync(currentUser.CompanyId); var arAccountId = await GetArAccountIdAsync(currentUser.CompanyId);
foreach (var item in invoice.InvoiceItems.Where(i => !i.IsDeleted)) foreach (var item in invoice.InvoiceItems.Where(i => !i.IsDeleted))
await _accountBalanceService.CreditAsync(item.RevenueAccountId, item.TotalPrice); await _accountBalanceService.CreditAsync(item.RevenueAccountId, item.TotalPrice);
if (invoice.TaxAmount > 0) if (invoice.TaxAmount > 0)
await _accountBalanceService.CreditAsync(invoice.SalesTaxAccountId, invoice.TaxAmount); await _accountBalanceService.CreditAsync(invoice.SalesTaxAccountId, invoice.TaxAmount);
await _accountBalanceService.DebitAsync(arAccountId, invoice.Total); await _accountBalanceService.DebitAsync(arAccountId, invoice.Total);
if (invoice.DiscountAmount > 0)
{
var discountAccountId = await GetSalesDiscountAccountIdAsync(currentUser.CompanyId);
await _accountBalanceService.DebitAsync(discountAccountId, invoice.DiscountAmount);
}
// GL for auto-applied deposits: DR Customer Deposits 2300 (clears the liability) / CR AR.
// The bank was already debited when the deposit was recorded, so Checking is not touched here.
if (pendingDeposits.Any())
{
var custDepositsAcctId = await GetCustomerDepositsAccountIdAsync(currentUser.CompanyId);
foreach (var dep in pendingDeposits)
{
await _accountBalanceService.DebitAsync(custDepositsAcctId, dep.Amount);
await _accountBalanceService.CreditAsync(arAccountId, dep.Amount);
}
}
await _unitOfWork.CompleteAsync(); await _unitOfWork.CompleteAsync();
// Auto-generate gift certificates for any GC line items // Auto-generate gift certificates for any GC line items
@@ -873,8 +894,17 @@ public class InvoicesController : Controller
var currentUser = await _userManager.GetUserAsync(User); var currentUser = await _userManager.GetUserAsync(User);
// Recalculate totals (tax is applied after discount, consistent with quotes) // Capture GL state before any mutations so the reversal is exact.
var oldTotal = invoice.Total; var oldTotal = invoice.Total;
var oldTaxAmount = invoice.TaxAmount;
var oldTaxAcctId = invoice.SalesTaxAccountId;
var oldDiscountAmt = invoice.DiscountAmount;
var oldItems = invoice.InvoiceItems
.Where(i => !i.IsDeleted)
.Select(i => (RevAcctId: i.RevenueAccountId, Price: i.TotalPrice))
.ToList();
// Recalculate totals (tax is applied after discount, consistent with quotes)
var subTotal = dto.InvoiceItems.Sum(i => i.TotalPrice); var subTotal = dto.InvoiceItems.Sum(i => i.TotalPrice);
var taxableAmount = subTotal - dto.DiscountAmount; var taxableAmount = subTotal - dto.DiscountAmount;
var taxAmount = Math.Round(taxableAmount * dto.TaxPercent / 100, 2); var taxAmount = Math.Round(taxableAmount * dto.TaxPercent / 100, 2);
@@ -940,6 +970,31 @@ public class InvoicesController : Controller
await _unitOfWork.Invoices.UpdateAsync(invoice); await _unitOfWork.Invoices.UpdateAsync(invoice);
await _unitOfWork.CompleteAsync(); await _unitOfWork.CompleteAsync();
// Reverse old GL entries then re-post new ones so account balances stay accurate.
// Reversal is the mirror of the original Create double-entry: swap every Debit↔Credit.
var arAccountId = await GetArAccountIdAsync(invoice.CompanyId);
int? discAcctId = null;
if (oldDiscountAmt > 0 || invoice.DiscountAmount > 0)
discAcctId = await GetSalesDiscountAccountIdAsync(invoice.CompanyId);
await _accountBalanceService.CreditAsync(arAccountId, oldTotal);
foreach (var (revAcctId, price) in oldItems)
await _accountBalanceService.DebitAsync(revAcctId, price);
if (oldTaxAmount > 0)
await _accountBalanceService.DebitAsync(oldTaxAcctId, oldTaxAmount);
if (oldDiscountAmt > 0)
await _accountBalanceService.CreditAsync(discAcctId, oldDiscountAmt);
await _accountBalanceService.DebitAsync(arAccountId, invoice.Total);
foreach (var item in invoice.InvoiceItems.Where(i => !i.IsDeleted))
await _accountBalanceService.CreditAsync(item.RevenueAccountId, item.TotalPrice);
if (invoice.TaxAmount > 0)
await _accountBalanceService.CreditAsync(invoice.SalesTaxAccountId, invoice.TaxAmount);
if (invoice.DiscountAmount > 0)
await _accountBalanceService.DebitAsync(discAcctId, invoice.DiscountAmount);
await _unitOfWork.CompleteAsync();
TempData["Success"] = "Invoice updated successfully."; TempData["Success"] = "Invoice updated successfully.";
// Optionally re-send the updated invoice PDF to the customer // Optionally re-send the updated invoice PDF to the customer
@@ -948,11 +1003,18 @@ public class InvoicesController : Controller
try try
{ {
var currentUserForPdf = await _userManager.GetUserAsync(User); var currentUserForPdf = await _userManager.GetUserAsync(User);
if (string.IsNullOrEmpty(invoice.PublicViewToken))
{
invoice.PublicViewToken = Guid.NewGuid().ToString("N");
await _unitOfWork.Invoices.UpdateAsync(invoice);
await _unitOfWork.CompleteAsync();
}
var pdfBytes = await BuildInvoicePdfAsync(invoice, invoice.CompanyId); var pdfBytes = await BuildInvoicePdfAsync(invoice, invoice.CompanyId);
string? paymentUrl = null; string? paymentUrl = null;
if (!string.IsNullOrEmpty(invoice.PaymentLinkToken)) if (!string.IsNullOrEmpty(invoice.PaymentLinkToken))
paymentUrl = $"{Request.Scheme}://{Request.Host}/pay/{invoice.PaymentLinkToken}"; paymentUrl = $"{Request.Scheme}://{Request.Host}/pay/{invoice.PaymentLinkToken}";
await _notificationService.NotifyInvoiceSentAsync(invoice, pdfBytes, $"Invoice-{invoice.InvoiceNumber}.pdf", paymentUrl); var viewUrl = $"{Request.Scheme}://{Request.Host}/invoice/{invoice.PublicViewToken}";
await _notificationService.NotifyInvoiceSentAsync(invoice, pdfBytes, $"Invoice-{invoice.InvoiceNumber}.pdf", paymentUrl, viewUrl: viewUrl);
var notifLog = await _unitOfWork.NotificationLogs.GetLatestForInvoiceAsync(id); var notifLog = await _unitOfWork.NotificationLogs.GetLatestForInvoiceAsync(id);
this.SetNotificationResultToast(notifLog); this.SetNotificationResultToast(notifLog);
} }
@@ -978,13 +1040,13 @@ public class InvoicesController : Controller
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------
/// <summary> /// <summary>
/// Marks a Draft invoice as Sent, optionally generates a Stripe online-payment link, and /// Marks a Draft invoice as Sent, optionally generates a Stripe online-payment link, and
/// fires the customer notification with a PDF attachment. Notification failure is caught /// fires the customer notification. Staff can choose email, SMS, or both via the modal.
/// separately and logged as a warning — a failed email must not roll back the status change. /// PublicViewToken is always generated (permanent view link for SMS); PaymentLinkToken is
/// The payment URL is assembled from the generated token and the current request host so it /// only generated when Stripe Connect is active (expiring pay link for email/view page).
/// works identically in dev (localhost) and production without config changes. /// Notification failure is caught separately — a failed send must not roll back the status change.
/// </summary> /// </summary>
[HttpPost, ValidateAntiForgeryToken] [HttpPost, ValidateAntiForgeryToken]
public async Task<IActionResult> Send(int id, string? overrideEmail = null) public async Task<IActionResult> Send(int id, string? overrideEmail = null, bool sendEmail = true, bool sendSms = false)
{ {
try try
{ {
@@ -1003,27 +1065,39 @@ public class InvoicesController : Controller
invoice.UpdatedAt = DateTime.UtcNow; invoice.UpdatedAt = DateTime.UtcNow;
invoice.UpdatedBy = currentUser?.Email; invoice.UpdatedBy = currentUser?.Email;
// Permanent view token — always generate so SMS always has a link
if (string.IsNullOrEmpty(invoice.PublicViewToken))
invoice.PublicViewToken = Guid.NewGuid().ToString("N");
await TryGeneratePaymentTokenAsync(invoice); await TryGeneratePaymentTokenAsync(invoice);
await _unitOfWork.Invoices.UpdateAsync(invoice); await _unitOfWork.Invoices.UpdateAsync(invoice);
await _unitOfWork.CompleteAsync(); await _unitOfWork.CompleteAsync();
// Generate PDF and send notification
string? paymentUrl = null; string? paymentUrl = null;
if (!string.IsNullOrEmpty(invoice.PaymentLinkToken)) if (!string.IsNullOrEmpty(invoice.PaymentLinkToken))
paymentUrl = $"{Request.Scheme}://{Request.Host}/pay/{invoice.PaymentLinkToken}"; paymentUrl = $"{Request.Scheme}://{Request.Host}/pay/{invoice.PaymentLinkToken}";
bool pdfAndNotifSucceeded = false; var viewUrl = $"{Request.Scheme}://{Request.Host}/invoice/{invoice.PublicViewToken}";
bool notifSucceeded = false;
try try
{ {
var pdfBytes = await BuildInvoicePdfAsync(invoice, currentUser!.CompanyId); byte[]? pdfBytes = null;
await _notificationService.NotifyInvoiceSentAsync(invoice, pdfBytes, $"Invoice-{invoice.InvoiceNumber}.pdf", paymentUrl, overrideEmail: overrideEmail?.Trim()); if (sendEmail)
pdfAndNotifSucceeded = true; pdfBytes = await BuildInvoicePdfAsync(invoice, currentUser!.CompanyId);
await _notificationService.NotifyInvoiceSentAsync(
invoice, pdfBytes, $"Invoice-{invoice.InvoiceNumber}.pdf",
paymentUrl, overrideEmail: overrideEmail?.Trim(),
sendSms: sendSms, viewUrl: viewUrl);
notifSucceeded = true;
} }
catch (Exception notifyEx) catch (Exception notifyEx)
{ {
_logger.LogError(notifyEx, _logger.LogError(notifyEx,
"Invoice {InvoiceId} ({InvoiceNumber}): PDF generation or email dispatch failed. " + "Invoice {InvoiceId} ({InvoiceNumber}): notification failed. " +
"Inner: {InnerMessage}. Invoice status was already saved as Sent.", "Inner: {InnerMessage}. Invoice status was already saved as Sent.",
id, invoice.InvoiceNumber, notifyEx.InnerException?.Message ?? "none"); id, invoice.InvoiceNumber, notifyEx.InnerException?.Message ?? "none");
} }
@@ -1032,8 +1106,8 @@ public class InvoicesController : Controller
this.SetNotificationResultToast(notifLog); this.SetNotificationResultToast(notifLog);
TempData["Success"] = $"Invoice {invoice.InvoiceNumber} marked as sent."; TempData["Success"] = $"Invoice {invoice.InvoiceNumber} marked as sent.";
if (!pdfAndNotifSucceeded) if (!notifSucceeded)
TempData["WarningPermanent"] = "The invoice is marked as sent, but PDF generation or the customer email failed. Check the notification logs or your email configuration."; TempData["WarningPermanent"] = "The invoice is marked as sent, but the notification failed. Check the notification logs or your configuration.";
return RedirectToAction(nameof(Details), new { id }); return RedirectToAction(nameof(Details), new { id });
} }
catch (Exception ex) catch (Exception ex)
@@ -1347,29 +1421,49 @@ public class InvoicesController : Controller
await _unitOfWork.Payments.SoftDeleteAsync(payment.Id); await _unitOfWork.Payments.SoftDeleteAsync(payment.Id);
} }
// Void any gift certificates that were generated from this invoice // Void any gift certificates that were generated from this invoice.
var gcItemIds = invoice.InvoiceItems // Capture each GC's remaining balance BEFORE voiding so the GL entries below can use it.
.Where(i => !i.IsDeleted && i.IsGiftCertificate && i.GeneratedGiftCertificateId.HasValue) var gcLiabilityAcctId = await GetGcLiabilityAccountIdAsync(invoice.CompanyId);
.Select(i => i.GeneratedGiftCertificateId!.Value) var gcRemainingByItemId = new Dictionary<int, decimal>(); // invoiceItemId → remaining balance
.ToList(); foreach (var gcItem in invoice.InvoiceItems.Where(i => !i.IsDeleted && i.IsGiftCertificate && i.GeneratedGiftCertificateId.HasValue))
foreach (var gcId in gcItemIds)
{ {
var gc = await _unitOfWork.GiftCertificates.GetByIdAsync(gcId); var gc = await _unitOfWork.GiftCertificates.GetByIdAsync(gcItem.GeneratedGiftCertificateId!.Value);
if (gc != null && gc.Status != GiftCertificateStatus.FullyRedeemed) if (gc != null && gc.Status != GiftCertificateStatus.FullyRedeemed)
{ {
gcRemainingByItemId[gcItem.Id] = gc.RemainingBalance;
gc.Status = GiftCertificateStatus.Voided; gc.Status = GiftCertificateStatus.Voided;
gc.UpdatedAt = DateTime.UtcNow; gc.UpdatedAt = DateTime.UtcNow;
await _unitOfWork.GiftCertificates.UpdateAsync(gc); await _unitOfWork.GiftCertificates.UpdateAsync(gc);
} }
// FullyRedeemed GCs: not voided, nothing to reverse (GC Liability already at 0).
} }
// Reverse account balances: credit AR (open balance), debit revenue + sales tax // Reverse account balances: credit AR (open balance), debit revenue + sales tax.
// Also reverse the discount contra-entry (credit Sales Discounts) to unwind the original debit.
// GC line items: instead of debiting revenue (which was already reclassified to GC Liability
// at creation), debit GC Liability for the unredeemed portion, netting the obligation to 0.
var arAccountId = await GetArAccountIdAsync(invoice.CompanyId); var arAccountId = await GetArAccountIdAsync(invoice.CompanyId);
await _accountBalanceService.CreditAsync(arAccountId, balanceDue); await _accountBalanceService.CreditAsync(arAccountId, balanceDue);
foreach (var item in invoice.InvoiceItems.Where(i => !i.IsDeleted)) foreach (var item in invoice.InvoiceItems.Where(i => !i.IsDeleted))
{
if (item.IsGiftCertificate)
{
// GC item: debit GC Liability for unredeemed portion; skip fully-redeemed items.
if (gcLiabilityAcctId.HasValue && gcRemainingByItemId.TryGetValue(item.Id, out var remaining) && remaining > 0)
await _accountBalanceService.DebitAsync(gcLiabilityAcctId, remaining);
}
else
{
await _accountBalanceService.DebitAsync(item.RevenueAccountId, item.TotalPrice); await _accountBalanceService.DebitAsync(item.RevenueAccountId, item.TotalPrice);
}
}
if (invoice.TaxAmount > 0) if (invoice.TaxAmount > 0)
await _accountBalanceService.DebitAsync(invoice.SalesTaxAccountId, invoice.TaxAmount); await _accountBalanceService.DebitAsync(invoice.SalesTaxAccountId, invoice.TaxAmount);
if (invoice.DiscountAmount > 0)
{
var discountAccountId = await GetSalesDiscountAccountIdAsync(invoice.CompanyId);
await _accountBalanceService.CreditAsync(discountAccountId, invoice.DiscountAmount);
}
invoice.Status = InvoiceStatus.Voided; invoice.Status = InvoiceStatus.Voided;
invoice.UpdatedAt = DateTime.UtcNow; invoice.UpdatedAt = DateTime.UtcNow;
@@ -1721,13 +1815,30 @@ public class InvoicesController : Controller
deposit.UpdatedAt = DateTime.UtcNow; deposit.UpdatedAt = DateTime.UtcNow;
} }
// Reverse account balances (mirror of Create): credit AR, debit revenue + sales tax // Reverse account balances (mirror of Create): credit AR, debit revenue + sales tax.
// Also reverse the discount contra-entry (credit Sales Discounts) to unwind the original debit.
var arAccountId = await GetArAccountIdAsync(invoice.CompanyId); var arAccountId = await GetArAccountIdAsync(invoice.CompanyId);
// Reverse deposit-apply GL: DR AR / CR Customer Deposits 2300 for each previously applied
// deposit. The deposits are now unapplied and the liability is restored.
if (appliedDeposits.Any())
{
var custDepositsAcctId = await GetCustomerDepositsAccountIdAsync(invoice.CompanyId);
foreach (var dep in appliedDeposits)
{
await _accountBalanceService.DebitAsync(arAccountId, dep.Amount);
await _accountBalanceService.CreditAsync(custDepositsAcctId, dep.Amount);
}
}
await _accountBalanceService.CreditAsync(arAccountId, invoice.Total); await _accountBalanceService.CreditAsync(arAccountId, invoice.Total);
foreach (var item in invoiceItems) foreach (var item in invoiceItems)
await _accountBalanceService.DebitAsync(item.RevenueAccountId, item.TotalPrice); await _accountBalanceService.DebitAsync(item.RevenueAccountId, item.TotalPrice);
if (invoice.TaxAmount > 0) if (invoice.TaxAmount > 0)
await _accountBalanceService.DebitAsync(invoice.SalesTaxAccountId, invoice.TaxAmount); await _accountBalanceService.DebitAsync(invoice.SalesTaxAccountId, invoice.TaxAmount);
if (invoice.DiscountAmount > 0)
{
var discountAccountId = await GetSalesDiscountAccountIdAsync(invoice.CompanyId);
await _accountBalanceService.CreditAsync(discountAccountId, invoice.DiscountAmount);
}
// Clear the JobId FK before soft-deleting so the unique index slot is freed // Clear the JobId FK before soft-deleting so the unique index slot is freed
// and a new invoice can be created for the same job if needed. // and a new invoice can be created for the same job if needed.
@@ -1920,6 +2031,12 @@ public class InvoicesController : Controller
item.GeneratedGiftCertificateId = cert.Id; item.GeneratedGiftCertificateId = cert.Id;
await _unitOfWork.InvoiceItems.UpdateAsync(item); await _unitOfWork.InvoiceItems.UpdateAsync(item);
// GL: DR Revenue (line item account) / CR Gift Certificate Liability (2500).
// Reclassifies the GC item's revenue as a deferred obligation until the cert is redeemed.
var gcLiabilityAcctId = await GetGcLiabilityAccountIdAsync(invoice.CompanyId);
await _accountBalanceService.DebitAsync(item.RevenueAccountId, item.TotalPrice);
await _accountBalanceService.CreditAsync(gcLiabilityAcctId, item.TotalPrice);
} }
await _unitOfWork.CompleteAsync(); await _unitOfWork.CompleteAsync();
@@ -2083,6 +2200,24 @@ public class InvoicesController : Controller
.ToList(); .ToList();
} }
/// <summary>Returns the primary Checking or Cash account ID for the company, used as the
/// deposit account when auto-applying deposits that were recorded without an explicit account.</summary>
private async Task<int?> GetCheckingAccountIdAsync(int companyId)
{
var acct = await _unitOfWork.Accounts.FirstOrDefaultAsync(
a => a.IsActive && (a.AccountSubType == AccountSubType.Checking
|| a.AccountSubType == AccountSubType.Cash));
return acct?.Id;
}
/// <summary>Returns account 2300 "Customer Deposits" liability ID for the company, or null.</summary>
private async Task<int?> GetCustomerDepositsAccountIdAsync(int companyId)
{
var acct = await _unitOfWork.Accounts.FirstOrDefaultAsync(
a => a.IsActive && a.AccountNumber == "2300");
return acct?.Id;
}
/// <summary>Returns the AR account ID for the given company (first active AccountsReceivable account).</summary> /// <summary>Returns the AR account ID for the given company (first active AccountsReceivable account).</summary>
private async Task<int?> GetArAccountIdAsync(int companyId) private async Task<int?> GetArAccountIdAsync(int companyId)
{ {
@@ -2135,6 +2270,28 @@ public class InvoicesController : Controller
return taxAccount?.Id; return taxAccount?.Id;
} }
/// <summary>
/// Looks up the "4950 Sales Discounts" contra-revenue account for this company, falling back
/// to any active Revenue account whose name contains "discount". Returns null only when no
/// such account exists (e.g. for companies whose chart of accounts predates the 4950 seed).
/// </summary>
private async Task<int?> GetSalesDiscountAccountIdAsync(int companyId)
{
var discountAccount = await _unitOfWork.Accounts.FirstOrDefaultAsync(
a => a.AccountNumber == "4950" && a.IsActive);
discountAccount ??= await _unitOfWork.Accounts.FirstOrDefaultAsync(
a => a.AccountType == AccountType.Revenue && a.IsActive && a.Name.ToLower().Contains("discount"));
return discountAccount?.Id;
}
/// <summary>Returns the Gift Certificate Liability account ID (account 2500) for the company.</summary>
private async Task<int?> GetGcLiabilityAccountIdAsync(int companyId)
{
var acct = await _unitOfWork.Accounts.FirstOrDefaultAsync(
a => a.IsActive && a.AccountNumber == "2500");
return acct?.Id;
}
public static string GetStatusColorClass(InvoiceStatus status) => status switch public static string GetStatusColorClass(InvoiceStatus status) => status switch
{ {
InvoiceStatus.Draft => "secondary", InvoiceStatus.Draft => "secondary",
@@ -2191,6 +2348,8 @@ public class InvoicesController : Controller
Amount = dto.Amount, Amount = dto.Amount,
RefundDate = dto.RefundDate, RefundDate = dto.RefundDate,
RefundMethod = dto.RefundMethod, RefundMethod = dto.RefundMethod,
// DepositAccountId only applies to cash/card refunds; store-credit refunds have no bank movement.
DepositAccountId = isStoreCredit ? null : dto.DepositAccountId,
Reason = dto.Reason, Reason = dto.Reason,
Reference = dto.Reference, Reference = dto.Reference,
Notes = dto.Notes, Notes = dto.Notes,
@@ -2249,6 +2408,14 @@ public class InvoicesController : Controller
} }
await _unitOfWork.CompleteAsync(); await _unitOfWork.CompleteAsync();
// GL: DR AR (un-collects the payment) / CR Bank (cash leaves).
// Mirrors how FinancialReportService accounts for refunds:
// arTotalCredits -= refundTotal; refundsByAcct credits the bank account.
var arAccountId = await GetArAccountIdAsync(companyId);
await _accountBalanceService.DebitAsync(arAccountId, dto.Amount);
await _accountBalanceService.CreditAsync(dto.DepositAccountId, dto.Amount);
TempData["Success"] = $"Refund of {dto.Amount:C} recorded successfully. Please issue the refund manually."; TempData["Success"] = $"Refund of {dto.Amount:C} recorded successfully. Please issue the refund manually.";
} }
} }
@@ -2323,6 +2490,11 @@ public class InvoicesController : Controller
customer.CurrentBalance += refund.Amount; customer.CurrentBalance += refund.Amount;
await _unitOfWork.Customers.UpdateAsync(customer); await _unitOfWork.Customers.UpdateAsync(customer);
} }
// GL reversal: CR AR / DR Bank — mirrors the DR AR / CR Bank posted in IssueRefund.
var arAccountId = await GetArAccountIdAsync(refund.Invoice.CompanyId);
await _accountBalanceService.CreditAsync(arAccountId, refund.Amount);
await _accountBalanceService.DebitAsync(refund.DepositAccountId, refund.Amount);
} }
refund.Status = RefundStatus.Cancelled; refund.Status = RefundStatus.Cancelled;
@@ -2469,6 +2641,12 @@ public class InvoicesController : Controller
await _unitOfWork.Invoices.UpdateAsync(invoice); await _unitOfWork.Invoices.UpdateAsync(invoice);
} }
// GL: DR Sales Discounts 4950 / CR AR — same as CreditMemosController.Apply.
var arAccountId = await GetArAccountIdAsync(invoice.CompanyId);
var discountAcctId = await GetSalesDiscountAccountIdAsync(invoice.CompanyId);
await _accountBalanceService.DebitAsync(discountAcctId, applyAmount);
await _accountBalanceService.CreditAsync(arAccountId, applyAmount);
await _unitOfWork.CompleteAsync(); await _unitOfWork.CompleteAsync();
}); // end ExecuteInTransactionAsync }); // end ExecuteInTransactionAsync
@@ -2629,6 +2807,13 @@ public class InvoicesController : Controller
await _unitOfWork.Customers.UpdateAsync(customer); await _unitOfWork.Customers.UpdateAsync(customer);
} }
// GL: DR Gift Certificate Liability (2500) / CR AR.
// Discharges the deferred obligation and reduces the invoice's outstanding AR balance.
var gcLiabilityAcctId = await GetGcLiabilityAccountIdAsync(invoice.CompanyId);
var arAcctId = await GetArAccountIdAsync(invoice.CompanyId);
await _accountBalanceService.DebitAsync(gcLiabilityAcctId, applyAmount);
await _accountBalanceService.CreditAsync(arAcctId, applyAmount);
await _unitOfWork.CompleteAsync(); await _unitOfWork.CompleteAsync();
TempData["Success"] = $"Gift certificate {cert.CertificateCode} — {applyAmount:C} applied to invoice."; TempData["Success"] = $"Gift certificate {cert.CertificateCode} — {applyAmount:C} applied to invoice.";
} }
@@ -0,0 +1,923 @@
using AutoMapper;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.SignalR;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging;
using PowderCoating.Application.DTOs.Kiosk;
using PowderCoating.Application.Interfaces;
using PowderCoating.Application.Services;
using PowderCoating.Core.Entities;
using PowderCoating.Core.Enums;
using PowderCoating.Core.Interfaces;
using PowderCoating.Shared.Constants;
using PowderCoating.Web.Hubs;
namespace PowderCoating.Web.Controllers;
/// <summary>
/// Handles the customer self-service intake kiosk — both the in-person tablet flow
/// (SignalR-triggered, activation-cookie-authenticated) and the remote email-link flow.
///
/// Anonymous intake routes use ignoreQueryFilters:true to load KioskSession by token
/// because the anonymous HTTP context has no CompanyId claim, so the global tenant
/// filter would return nothing without that flag.
///
/// When creating new Customer or Job records from the kiosk, CompanyId is set explicitly
/// from session.CompanyId so the EF SaveChanges interceptor doesn't override it with 0.
/// </summary>
public class KioskController : Controller
{
private const string CookieName = "KioskDevice";
private const int InPersonExpireHours = 2;
private const int RemoteExpireHours = 48;
private readonly IUnitOfWork _unitOfWork;
private readonly IMapper _mapper;
private readonly ILookupCacheService _lookupCache;
private readonly IInAppNotificationService _inApp;
private readonly IEmailService _emailService;
private readonly IHubContext<KioskHub> _kioskHub;
private readonly ILogger<KioskController> _logger;
private readonly ICompanyLogoService _logoService;
private readonly IMemoryCache _cache;
private static string SmsConsentCacheKey(int companyId) => $"kiosk-sms-consent:{companyId}";
/// <summary>Initialises all dependencies for the kiosk controller.</summary>
public KioskController(
IUnitOfWork unitOfWork,
IMapper mapper,
ILookupCacheService lookupCache,
IInAppNotificationService inApp,
IEmailService emailService,
IHubContext<KioskHub> kioskHub,
ILogger<KioskController> logger,
ICompanyLogoService logoService,
IMemoryCache cache)
{
_unitOfWork = unitOfWork;
_mapper = mapper;
_lookupCache = lookupCache;
_inApp = inApp;
_emailService = emailService;
_kioskHub = kioskHub;
_logger = logger;
_logoService = logoService;
_cache = cache;
}
// =========================================================================
// WELCOME SCREEN (in-person tablet idle screen)
// =========================================================================
/// <summary>
/// Idle branded screen displayed on the front-desk tablet.
/// Validates the KioskDevice cookie; returns 403 if missing or token mismatch.
/// The view polls /Kiosk/PollSession every 3 seconds and navigates when staff
/// triggers a session via the Dashboard "Start Intake" button.
/// </summary>
[AllowAnonymous]
public async Task<IActionResult> Welcome()
{
var cookie = ReadKioskCookie();
if (cookie == null)
return View("KioskError", "This device is not activated as a kiosk. Ask a staff member to activate it at Settings → Kiosk.");
var company = await _unitOfWork.Companies.GetByIdAsync(cookie.Value.companyId, ignoreQueryFilters: true);
if (company == null || company.KioskActivationToken != cookie.Value.token)
return View("KioskError", "Kiosk activation token is invalid or has been revoked. Ask a staff member to re-activate this device.");
await PopulateKioskViewBag(company);
ViewBag.ShowInactivityTimer = false; // Welcome screen stays on indefinitely
return View();
}
/// <summary>
/// Lightweight polling endpoint called every 3 seconds by the kiosk Welcome screen.
/// Returns the most recent InPerson KioskSession created in the last 60 seconds so
/// the tablet can navigate without relying on SignalR (which Azure App Service blocks
/// for anonymous WebSocket/SSE connections through its ingress proxy).
/// </summary>
[AllowAnonymous, HttpGet]
[ResponseCache(NoStore = true, Location = ResponseCacheLocation.None)]
public async Task<IActionResult> PollSession()
{
var cookie = ReadKioskCookie();
if (cookie == null) return Json(new { hasSession = false });
var company = await _unitOfWork.Companies.GetByIdAsync(cookie.Value.companyId, ignoreQueryFilters: true);
if (company == null || company.KioskActivationToken != cookie.Value.token)
return Json(new { hasSession = false });
// Check for a staff-pushed SMS consent request before checking for intake sessions.
if (_cache.TryGetValue(SmsConsentCacheKey(cookie.Value.companyId), out (int customerId, string customerName) pending))
return Json(new { hasSession = false, smsConsentPending = true, customerId = pending.customerId, customerName = pending.customerName });
var window = DateTime.UtcNow.AddSeconds(-60);
var session = await _unitOfWork.KioskSessions.FirstOrDefaultAsync(
s => s.CompanyId == cookie.Value.companyId
&& s.SessionType == KioskSessionType.InPerson
&& s.Status == KioskSessionStatus.Active
&& s.CreatedAt >= window,
ignoreQueryFilters: true);
if (session == null) return Json(new { hasSession = false });
return Json(new { hasSession = true, sessionToken = session.SessionToken });
}
// =========================================================================
// SMS CONSENT (staff pushes to kiosk; customer agrees on tablet)
// =========================================================================
/// <summary>
/// Staff calls this (authenticated) from the Customer Details page to push an SMS
/// consent request to the front-desk kiosk tablet. Stores the customer ID in
/// IMemoryCache under a company-scoped key; the kiosk's PollSession endpoint picks
/// it up and returns smsConsentPending so the tablet can navigate to the consent page.
/// The cache entry expires in 10 minutes in case the customer never approaches the tablet.
/// </summary>
[HttpPost, ValidateAntiForgeryToken]
public async Task<IActionResult> PushSmsConsent(int customerId)
{
var customer = await _unitOfWork.Customers.GetByIdAsync(customerId);
if (customer == null) return Json(new { success = false, message = "Customer not found." });
if (customer.NotifyBySms)
return Json(new { success = false, message = "Customer has already given SMS consent." });
var companyId = customer.CompanyId;
var name = !string.IsNullOrWhiteSpace(customer.ContactFirstName)
? $"{customer.ContactFirstName} {customer.ContactLastName}".Trim()
: customer.CompanyName ?? "Customer";
_cache.Set(SmsConsentCacheKey(companyId), (customerId, name),
new MemoryCacheEntryOptions { AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(10) });
_logger.LogInformation("SMS consent pushed to kiosk for customer {CustomerId} by staff", customerId);
return Json(new { success = true });
}
/// <summary>
/// Cancels a pending kiosk SMS consent request, freeing the kiosk to return to the Welcome
/// screen. Called by staff if they pushed consent accidentally or the customer isn't coming.
/// </summary>
[HttpPost, ValidateAntiForgeryToken]
public IActionResult CancelSmsConsent()
{
var companyId = HttpContext.User.FindFirst("CompanyId")?.Value;
if (int.TryParse(companyId, out var cid))
_cache.Remove(SmsConsentCacheKey(cid));
return Json(new { success = true });
}
/// <summary>
/// Displays the full-screen SMS consent form on the kiosk tablet (anonymous, kiosk layout).
/// Loads the customer by ID with ignoreQueryFilters because the kiosk has no tenant context.
/// </summary>
[AllowAnonymous]
public async Task<IActionResult> SmsConsent(int id)
{
var cookie = ReadKioskCookie();
if (cookie == null) return Forbid();
// Clear the pending entry immediately — the kiosk is now showing the form,
// so Welcome must not redirect again if the customer cancels or navigates back.
_cache.Remove(SmsConsentCacheKey(cookie.Value.companyId));
var customer = await _unitOfWork.Customers.GetByIdAsync(id, ignoreQueryFilters: true);
if (customer == null) return NotFound();
var company = await _unitOfWork.Companies.GetByIdAsync(cookie.Value.companyId, ignoreQueryFilters: true);
ViewBag.CompanyName = company?.CompanyName;
ViewBag.CompanyLogoUrl = !string.IsNullOrEmpty(company?.LogoFilePath) ? Url.Action("Logo", "Kiosk") : null;
ViewBag.ShowInactivityTimer = false;
ViewBag.CustomerName = !string.IsNullOrWhiteSpace(customer.ContactFirstName)
? $"{customer.ContactFirstName} {customer.ContactLastName}".Trim()
: customer.CompanyName ?? "Customer";
return View(id);
}
/// <summary>
/// Records the customer's SMS consent from the kiosk tablet.
/// Sets NotifyBySms, SmsConsentedAt, SmsConsentMethod = "KioskInPerson" on the customer record.
/// Cache is already cleared by the GET; this handles the agree/decline outcome.
/// </summary>
[AllowAnonymous, HttpPost]
public async Task<IActionResult> SmsConsent(int id, bool agreed)
{
var cookie = ReadKioskCookie();
if (cookie == null) return Forbid();
if (agreed)
{
var customer = await _unitOfWork.Customers.GetByIdAsync(id, ignoreQueryFilters: true);
if (customer != null)
{
customer.NotifyBySms = true;
customer.SmsConsentedAt = DateTime.UtcNow;
customer.SmsConsentMethod = "KioskInPerson";
customer.SmsOptedOutAt = null;
await _unitOfWork.Customers.UpdateAsync(customer);
await _unitOfWork.CompleteAsync();
_logger.LogInformation("SMS consent recorded via kiosk for customer {CustomerId}", id);
await _inApp.CreateAsync(
customer.CompanyId,
"SMS Consent Recorded",
$"{customer.ContactFirstName} {customer.ContactLastName} agreed to SMS notifications on the kiosk.",
"KioskConsent",
link: $"/Customers/Details/{id}",
customerId: id);
}
}
return Redirect("/Kiosk/Welcome");
}
/// <summary>
/// Serves the company logo for anonymous kiosk pages. Resolves the company from the
/// KioskDevice cookie so no tenant context is needed on the anonymous request.
/// </summary>
[AllowAnonymous]
[HttpGet, ResponseCache(Duration = 3600, Location = ResponseCacheLocation.Any)]
public async Task<IActionResult> Logo()
{
var cookie = ReadKioskCookie();
if (cookie == null) return NotFound();
var company = await _unitOfWork.Companies.GetByIdAsync(cookie.Value.companyId, ignoreQueryFilters: true);
if (company == null || string.IsNullOrEmpty(company.LogoFilePath)) return NotFound();
var (success, fileContent, contentType, _) = await _logoService.GetCompanyLogoAsync(company.LogoFilePath);
if (!success || fileContent.Length == 0) return NotFound();
return File(fileContent, contentType);
}
// =========================================================================
// DEVICE ACTIVATION (CompanyAdmin-only)
// =========================================================================
/// <summary>Shows the kiosk activation page with the current activation status.</summary>
[Authorize(Policy = AppConstants.Policies.CompanyAdminOnly)]
public async Task<IActionResult> Activate()
{
var companyId = GetCurrentCompanyId();
var company = await _unitOfWork.Companies.GetByIdAsync(companyId, ignoreQueryFilters: true);
ViewBag.IsActivated = !string.IsNullOrEmpty(company?.KioskActivationToken);
return View();
}
/// <summary>
/// Generates a new activation token, saves it to the Company record,
/// and writes the KioskDevice cookie so the current browser session becomes the active tablet.
/// </summary>
[HttpPost, ValidateAntiForgeryToken]
[Authorize(Policy = AppConstants.Policies.CompanyAdminOnly)]
public async Task<IActionResult> Activate(string action)
{
var companyId = GetCurrentCompanyId();
var company = await _unitOfWork.Companies.GetByIdAsync(companyId, ignoreQueryFilters: true);
if (company == null) return NotFound();
if (action == "deactivate")
{
company.KioskActivationToken = null;
DeleteKioskCookie();
TempData["Success"] = "Kiosk deactivated. The tablet will no longer accept intake sessions.";
}
else
{
var token = Guid.NewGuid().ToString("N");
company.KioskActivationToken = token;
WriteKioskCookie(companyId, token);
TempData["Success"] = "Kiosk activated. Open /Kiosk/Welcome on the tablet and bookmark it.";
}
await _unitOfWork.CompleteAsync();
return RedirectToAction(nameof(Activate));
}
// =========================================================================
// START IN-PERSON SESSION (any authenticated staff member)
// =========================================================================
/// <summary>
/// Creates an InPerson KioskSession and pushes a SignalR StartIntake event
/// to all connections in the company's kiosk group so the tablet navigates automatically.
/// Called via fetch from the Dashboard "Start Intake" button.
/// </summary>
[HttpPost, ValidateAntiForgeryToken]
[Authorize]
public async Task<IActionResult> StartSession()
{
var companyId = GetCurrentCompanyId();
var session = new KioskSession
{
SessionType = KioskSessionType.InPerson,
ExpiresAt = DateTime.UtcNow.AddHours(InPersonExpireHours),
CompanyId = companyId
};
await _unitOfWork.KioskSessions.AddAsync(session);
await _unitOfWork.CompleteAsync();
await _kioskHub.Clients
.Group($"kiosk-{companyId}")
.SendAsync("StartIntake", session.SessionToken.ToString());
return Json(new { success = true, sessionToken = session.SessionToken });
}
// =========================================================================
// SEND REMOTE LINK (any authenticated staff member)
// =========================================================================
/// <summary>Form for staff to enter a customer's email address and send an intake link.</summary>
[Authorize]
public IActionResult SendRemoteLink() => View(new SendRemoteLinkDto());
/// <summary>
/// Creates a Remote KioskSession, sends the intake link by email, and redirects back
/// with a success message. The link contains the session token (GUID) — not guessable.
/// </summary>
[HttpPost, ValidateAntiForgeryToken]
[Authorize]
public async Task<IActionResult> SendRemoteLink(SendRemoteLinkDto dto)
{
if (!ModelState.IsValid) return View(dto);
var companyId = GetCurrentCompanyId();
var company = await _unitOfWork.Companies.GetByIdAsync(companyId, ignoreQueryFilters: true);
var session = new KioskSession
{
SessionType = KioskSessionType.Remote,
ExpiresAt = DateTime.UtcNow.AddHours(RemoteExpireHours),
RemoteLinkEmail = dto.Email,
RemoteLinkSentAt = DateTime.UtcNow,
CompanyId = companyId
};
await _unitOfWork.KioskSessions.AddAsync(session);
await _unitOfWork.CompleteAsync();
var link = $"{Request.Scheme}://{Request.Host}/Kiosk/Intake/{session.SessionToken}/Contact";
var recipientName = string.IsNullOrWhiteSpace(dto.CustomerName) ? "Valued Customer" : dto.CustomerName;
var companyName = company?.CompanyName ?? "Us";
var html = $@"
<div style='font-family:sans-serif;max-width:560px;margin:0 auto;padding:2rem;'>
<h2 style='color:#1e293b;'>Hi {System.Web.HttpUtility.HtmlEncode(recipientName)},</h2>
<p style='color:#475569;font-size:1rem;'>
{System.Web.HttpUtility.HtmlEncode(companyName)} has sent you a quick intake form to fill out before your visit.
It only takes a couple of minutes.
</p>
<a href='{link}' style='display:inline-block;margin:1.5rem 0;padding:1rem 2rem;background:#2563eb;
color:#fff;font-weight:600;border-radius:8px;text-decoration:none;font-size:1.1rem;'>
Start My Intake Form
</a>
<p style='color:#94a3b8;font-size:0.85rem;'>
This link expires in 48 hours. If you did not expect this email, you can ignore it.
</p>
</div>";
await _emailService.SendEmailAsync(
dto.Email, recipientName,
$"Your intake form from {companyName}",
$"Please visit this link to complete your intake form: {link}",
htmlBody: html);
TempData["Success"] = $"Intake link sent to {dto.Email}.";
return RedirectToAction(nameof(SendRemoteLink));
}
// =========================================================================
// INTAKE STEPS (anonymous — both InPerson and Remote)
// =========================================================================
// ── Step 1: Contact Info ──────────────────────────────────────────────────
/// <summary>Displays the contact-info form for the given session token.</summary>
[AllowAnonymous]
public async Task<IActionResult> Contact(Guid token)
{
var session = await LoadSessionAsync(token);
if (session == null) return View("KioskError", "This intake session could not be found. Please ask a staff member to start a new one.");
if (!await ValidateSessionState(session)) return RedirectToAction(nameof(Confirmation), new { token });
await PopulateKioskViewBagFromSession(session);
ViewBag.KioskStep = 1;
return View("Intake/Contact", new SubmitKioskContactDto
{
FirstName = session.CustomerFirstName,
LastName = session.CustomerLastName,
Phone = session.CustomerPhone,
Email = session.CustomerEmail,
IsReturningCustomer = session.IsReturningCustomer
});
}
/// <summary>Saves contact info to the session and advances to Step 2.</summary>
[HttpPost, ValidateAntiForgeryToken]
[AllowAnonymous]
public async Task<IActionResult> Contact(Guid token, SubmitKioskContactDto dto)
{
var session = await LoadSessionAsync(token);
if (session == null) return View("KioskError", "Session not found.");
if (!ModelState.IsValid)
{
await PopulateKioskViewBagFromSession(session);
ViewBag.KioskStep = 1;
return View("Intake/Contact", dto);
}
session.CustomerFirstName = dto.FirstName.Trim();
session.CustomerLastName = dto.LastName.Trim();
session.CustomerPhone = dto.Phone.Trim();
session.CustomerEmail = dto.Email.Trim().ToLowerInvariant();
session.IsReturningCustomer = dto.IsReturningCustomer;
await _unitOfWork.CompleteAsync();
return RedirectToAction(nameof(Job), new { token });
}
// ── Step 2: Job Description ───────────────────────────────────────────────
/// <summary>Displays the job-description form.</summary>
[AllowAnonymous]
public async Task<IActionResult> Job(Guid token)
{
var session = await LoadSessionAsync(token);
if (session == null) return View("KioskError", "Session not found.");
if (!await ValidateSessionState(session)) return RedirectToAction(nameof(Confirmation), new { token });
await PopulateKioskViewBagFromSession(session);
ViewBag.KioskStep = 2;
return View("Intake/Job", new SubmitKioskJobDto
{
JobDescription = session.JobDescription,
HowDidYouHearAboutUs = session.HowDidYouHearAboutUs
});
}
/// <summary>Saves the job description and advances to Step 3.</summary>
[HttpPost, ValidateAntiForgeryToken]
[AllowAnonymous]
public async Task<IActionResult> Job(Guid token, SubmitKioskJobDto dto)
{
var session = await LoadSessionAsync(token);
if (session == null) return View("KioskError", "Session not found.");
if (!ModelState.IsValid)
{
await PopulateKioskViewBagFromSession(session);
ViewBag.KioskStep = 2;
return View("Intake/Job", dto);
}
session.JobDescription = dto.JobDescription.Trim();
session.HowDidYouHearAboutUs = dto.HowDidYouHearAboutUs?.Trim();
await _unitOfWork.CompleteAsync();
return RedirectToAction(nameof(Terms), new { token });
}
// ── Step 3: Terms & Consent ───────────────────────────────────────────────
/// <summary>Displays the terms, SMS opt-in checkbox, and (for InPerson) signature pad.</summary>
[AllowAnonymous]
public async Task<IActionResult> Terms(Guid token)
{
var session = await LoadSessionAsync(token);
if (session == null) return View("KioskError", "Session not found.");
if (!await ValidateSessionState(session)) return RedirectToAction(nameof(Confirmation), new { token });
await PopulateKioskViewBagFromSession(session);
ViewBag.KioskStep = 3;
ViewBag.IsInPerson = session.SessionType == KioskSessionType.InPerson;
return View("Intake/Terms", new SubmitKioskTermsDto());
}
/// <summary>
/// Saves terms agreement, triggers customer/job auto-creation, fires staff notification,
/// and redirects to the Confirmation screen.
/// </summary>
[HttpPost, ValidateAntiForgeryToken]
[AllowAnonymous]
public async Task<IActionResult> Terms(Guid token, SubmitKioskTermsDto dto)
{
var session = await LoadSessionAsync(token);
if (session == null) return View("KioskError", "Session not found.");
// Expired/already-submitted sessions go straight to Confirmation
if (!await ValidateSessionState(session)) return RedirectToAction(nameof(Confirmation), new { token });
// Require signature for in-person sessions
if (session.SessionType == KioskSessionType.InPerson &&
string.IsNullOrEmpty(dto.SignatureDataBase64))
{
ModelState.AddModelError("SignatureDataBase64", "Please sign above before continuing.");
}
if (!ModelState.IsValid)
{
await PopulateKioskViewBagFromSession(session);
ViewBag.KioskStep = 3;
ViewBag.IsInPerson = session.SessionType == KioskSessionType.InPerson;
return View("Intake/Terms", dto);
}
session.AgreedToTerms = true;
session.AgreedToTermsAt = DateTime.UtcNow;
session.SmsOptIn = dto.SmsOptIn;
session.SignatureDataBase64 = dto.SignatureDataBase64;
session.Status = KioskSessionStatus.Submitted;
session.SubmittedAt = DateTime.UtcNow;
try
{
await ProcessSubmissionAsync(session);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error processing kiosk submission for session {SessionToken}", token);
// Customer-facing page always succeeds — staff can convert the session manually.
// Persist the session's agreed/submitted state even if job creation failed.
try { await _unitOfWork.CompleteAsync(); } catch { /* best-effort */ }
}
return RedirectToAction(nameof(Confirmation), new { token });
}
// ── Confirmation ──────────────────────────────────────────────────────────
/// <summary>Thank-you screen shown after a successful submission.</summary>
[AllowAnonymous]
public async Task<IActionResult> Confirmation(Guid token)
{
var session = await LoadSessionAsync(token);
if (session == null) return View("KioskError", "Session not found.");
await PopulateKioskViewBagFromSession(session);
ViewBag.ShowInactivityTimer = false; // Handled by the countdown JS in the view
ViewBag.IsInPerson = session.SessionType == KioskSessionType.InPerson;
ViewBag.FirstName = session.CustomerFirstName;
return View("Intake/Confirmation");
}
// =========================================================================
// STAFF REVIEW (authenticated)
// =========================================================================
/// <summary>
/// Lists all kiosk intake sessions for the current company — submitted, active, and expired.
/// Manager or higher access required.
/// </summary>
[Authorize]
public async Task<IActionResult> Intakes(string? filter)
{
var sessions = await _unitOfWork.KioskSessions.GetAllAsync(false,
s => s.LinkedCustomer,
s => s.LinkedJob);
var dtos = sessions
.OrderByDescending(s => s.CreatedAt)
.Select(s => new KioskSessionListDto
{
Id = s.Id,
SessionToken = s.SessionToken,
SessionType = s.SessionType,
Status = s.Status,
CustomerFirstName = s.CustomerFirstName,
CustomerLastName = s.CustomerLastName,
CustomerEmail = s.CustomerEmail,
CustomerPhone = s.CustomerPhone,
JobDescription = s.JobDescription,
SmsOptIn = s.SmsOptIn,
SubmittedAt = s.SubmittedAt,
ExpiresAt = s.ExpiresAt,
LinkedCustomerId = s.LinkedCustomerId,
LinkedJobId = s.LinkedJobId,
LinkedQuoteId = s.LinkedQuoteId,
RemoteLinkEmail = s.RemoteLinkEmail
})
.ToList();
// Apply filter tab
dtos = filter switch
{
"submitted" => dtos.Where(d => d.Status == KioskSessionStatus.Submitted).ToList(),
"active" => dtos.Where(d => d.Status == KioskSessionStatus.Active && !d.IsExpired).ToList(),
"expired" => dtos.Where(d => d.IsExpired || d.Status == KioskSessionStatus.Expired).ToList(),
_ => dtos
};
ViewBag.ActiveFilter = filter ?? "all";
return View(dtos);
}
// =========================================================================
// PRIVATE HELPERS
// =========================================================================
/// <summary>
/// Loads a KioskSession by SessionToken using ignoreQueryFilters because anonymous requests
/// have no CompanyId claim, so the global tenant filter would return nothing without it.
/// </summary>
private async Task<KioskSession?> LoadSessionAsync(Guid token)
{
return await _unitOfWork.KioskSessions.FirstOrDefaultAsync(
s => s.SessionToken == token && !s.IsDeleted,
ignoreQueryFilters: true);
}
/// <summary>
/// Validates that the session is still in a usable state.
/// Returns false (and optionally updates status to Expired) if the session should not proceed.
/// </summary>
private async Task<bool> ValidateSessionState(KioskSession session)
{
if (session.Status == KioskSessionStatus.Submitted)
return false; // Already done — redirect to Confirmation (idempotent)
if (session.Status == KioskSessionStatus.Cancelled)
return false;
if (DateTime.UtcNow > session.ExpiresAt && session.Status == KioskSessionStatus.Active)
{
session.Status = KioskSessionStatus.Expired;
await _unitOfWork.CompleteAsync();
return false;
}
return session.Status == KioskSessionStatus.Active;
}
/// <summary>
/// Core submission logic: matches or creates a Customer, creates a Pending Job,
/// applies SMS consent, and fires a staff in-app notification.
/// CompanyId is set explicitly on new entities from session.CompanyId so the EF
/// SaveChanges interceptor does not override it with 0 (the anonymous tenant context).
/// </summary>
private async Task ProcessSubmissionAsync(KioskSession session)
{
var companyId = session.CompanyId;
// 1. Match or create Customer
Customer? customer = null;
if (!string.IsNullOrEmpty(session.CustomerEmail))
{
customer = await _unitOfWork.Customers.FirstOrDefaultAsync(
c => c.CompanyId == companyId && c.Email == session.CustomerEmail && !c.IsDeleted,
ignoreQueryFilters: true);
}
if (customer == null && !string.IsNullOrEmpty(session.CustomerPhone))
{
customer = await _unitOfWork.Customers.FirstOrDefaultAsync(
c => c.CompanyId == companyId && (c.Phone == session.CustomerPhone || c.MobilePhone == session.CustomerPhone) && !c.IsDeleted,
ignoreQueryFilters: true);
}
bool isNewCustomer = customer == null;
if (isNewCustomer)
{
customer = new Customer
{
CompanyId = companyId,
ContactFirstName = session.CustomerFirstName,
ContactLastName = session.CustomerLastName,
Phone = session.CustomerPhone,
Email = session.CustomerEmail,
IsActive = true,
IsCommercial = false
};
await _unitOfWork.Customers.AddAsync(customer);
await _unitOfWork.CompleteAsync(); // get Customer.Id
}
// 2. Apply SMS consent
if (session.SmsOptIn)
{
customer!.NotifyBySms = true;
customer.SmsConsentedAt = session.SubmittedAt ?? DateTime.UtcNow;
customer.SmsConsentMethod = session.SessionType == KioskSessionType.InPerson
? "KioskIntake"
: "RemoteIntake";
}
// 3. Resolve company preference: create a Quote (default) or a Job
var prefs = await _unitOfWork.CompanyPreferences.FirstOrDefaultAsync(
p => p.CompanyId == companyId && !p.IsDeleted, ignoreQueryFilters: true);
var intakeOutput = prefs?.KioskIntakeOutput ?? "Quote";
var createQuote = !string.Equals(intakeOutput, "Job", StringComparison.OrdinalIgnoreCase);
session.LinkedCustomerId = customer!.Id;
if (createQuote)
{
// 3a. Create a Draft Quote so staff can price and send for approval
var quoteStatuses = await _lookupCache.GetQuoteStatusLookupsAsync(companyId);
var draftStatus = quoteStatuses.FirstOrDefault(s => s.StatusCode == AppConstants.StatusCodes.Quote.Draft);
if (draftStatus == null)
throw new InvalidOperationException($"No Draft quote status found for company {companyId}. Run Seed Data from Platform Management.");
var quoteNumber = await GenerateQuoteNumberAsync(companyId);
var quote = new Quote
{
CompanyId = companyId,
CustomerId = customer.Id,
QuoteNumber = quoteNumber,
QuoteStatusId = draftStatus.Id,
Description = session.JobDescription,
Notes = $"Source: {session.SessionType} kiosk intake",
QuoteDate = DateTime.UtcNow,
ExpirationDate = DateTime.UtcNow.AddDays(prefs?.DefaultQuoteValidityDays ?? 30)
};
await _unitOfWork.Quotes.AddAsync(quote);
await _unitOfWork.CompleteAsync(); // quote.Id now valid
session.LinkedQuoteId = quote.Id;
}
else
{
// 3b. Create a Pending Job directly (for shops that price on the spot)
var jobStatuses = await _lookupCache.GetJobStatusLookupsAsync(companyId);
var pendingStatus = jobStatuses.FirstOrDefault(s => s.StatusCode == AppConstants.StatusCodes.Job.Pending);
if (pendingStatus == null)
throw new InvalidOperationException($"No Pending job status found for company {companyId}. Run Seed Data from Platform Management.");
var priorities = await _lookupCache.GetJobPriorityLookupsAsync(companyId);
var normalPriority = priorities.FirstOrDefault(p => p.PriorityCode == "NORMAL")
?? priorities.FirstOrDefault();
if (normalPriority == null)
throw new InvalidOperationException($"No job priority rows found for company {companyId}. Run Seed Data from Platform Management.");
var jobNumber = await GenerateJobNumberAsync(companyId);
var job = new Job
{
CompanyId = companyId,
CustomerId = customer.Id,
JobNumber = jobNumber,
JobStatusId = pendingStatus.Id,
JobPriorityId = normalPriority.Id,
Description = session.JobDescription,
SpecialInstructions = $"Source: {session.SessionType} kiosk intake"
};
await _unitOfWork.Jobs.AddAsync(job);
await _unitOfWork.CompleteAsync(); // job.Id now valid
session.LinkedJobId = job.Id;
}
// 4. Persist session links
await _unitOfWork.CompleteAsync();
// 5. Fire staff notification
var jobDesc = session.JobDescription ?? "";
var snippet = jobDesc.Length > 60 ? jobDesc[..60] + "…" : jobDesc;
var fullName = $"{session.CustomerFirstName} {session.CustomerLastName}".Trim();
var intakeLabel = session.SessionType == KioskSessionType.Remote ? "Remote Intake" : "Walk-in Intake";
await _inApp.CreateAsync(
companyId,
$"{intakeLabel} Submitted",
$"{fullName} completed their intake form — {snippet}",
"KioskIntake",
link: $"/Kiosk/Intakes",
customerId: customer.Id);
}
/// <summary>
/// Generates the next sequential quote number using the company's configured prefix.
/// Mirrors GenerateQuoteNumberAsync in QuotesController — same format: PREFIX-YYMM-####.
/// Implemented here because KioskController processes anonymous requests and cannot
/// rely on ITenantContext to resolve the company ID.
/// </summary>
private async Task<string> GenerateQuoteNumberAsync(int companyId)
{
var now = DateTime.UtcNow;
var prefs = await _unitOfWork.CompanyPreferences.FirstOrDefaultAsync(
p => p.CompanyId == companyId && !p.IsDeleted, ignoreQueryFilters: true);
var quotePrefix = !string.IsNullOrWhiteSpace(prefs?.QuoteNumberPrefix) ? prefs.QuoteNumberPrefix : "QT";
var prefix = $"{quotePrefix}-{now:yy}{now:MM}";
var lastQuoteNumber = await _unitOfWork.Quotes.GetLastQuoteNumberByPrefixAsync(companyId, prefix);
if (lastQuoteNumber != null)
{
var lastNumberStr = lastQuoteNumber[(prefix.Length + 1)..];
if (int.TryParse(lastNumberStr, out int lastNumber))
return $"{prefix}-{(lastNumber + 1):D4}";
}
return $"{prefix}-0001";
}
/// <summary>
/// Generates the next sequential job number using the company's configured prefix.
/// Mirrors the logic in JobsController.GenerateJobNumber() — same format: PREFIX-YYMM-####.
/// </summary>
private async Task<string> GenerateJobNumberAsync(int companyId)
{
var year = DateTime.Now.Year.ToString()[2..];
var month = DateTime.Now.Month.ToString("D2");
var prefs = await _unitOfWork.CompanyPreferences.FirstOrDefaultAsync(
p => p.CompanyId == companyId && !p.IsDeleted, ignoreQueryFilters: true);
var jobPrefix = !string.IsNullOrWhiteSpace(prefs?.JobNumberPrefix) ? prefs.JobNumberPrefix : "JOB";
var prefix = $"{jobPrefix}-{year}{month}";
var lastJobNumber = await _unitOfWork.Jobs.GetLastJobNumberByPrefixAsync(companyId, prefix);
if (lastJobNumber != null)
{
var lastNumberStr = lastJobNumber[(prefix.Length + 1)..];
if (int.TryParse(lastNumberStr, out int lastNumber))
return $"{prefix}-{(lastNumber + 1):D4}";
}
return $"{prefix}-0001";
}
/// <summary>
/// Reads the KioskDevice cookie and parses the "{companyId}:{token}" value.
/// Returns null if the cookie is absent or malformed.
/// </summary>
private (int companyId, string token)? ReadKioskCookie()
{
if (!Request.Cookies.TryGetValue(CookieName, out var raw) || string.IsNullOrEmpty(raw))
return null;
var parts = raw.Split(':', 2);
if (parts.Length != 2 || !int.TryParse(parts[0], out int id))
return null;
return (id, parts[1]);
}
/// <summary>Writes a long-lived HttpOnly kiosk device cookie.</summary>
private void WriteKioskCookie(int companyId, string token)
{
Response.Cookies.Append(CookieName, $"{companyId}:{token}", new CookieOptions
{
HttpOnly = true,
Secure = true,
SameSite = SameSiteMode.Lax,
MaxAge = TimeSpan.FromDays(365)
});
}
/// <summary>Removes the kiosk device cookie (deactivation).</summary>
private void DeleteKioskCookie()
{
Response.Cookies.Delete(CookieName);
}
/// <summary>Returns the current authenticated user's CompanyId claim.</summary>
private int GetCurrentCompanyId()
{
var claim = User.FindFirst("CompanyId")?.Value;
return int.TryParse(claim, out int id) ? id : 0;
}
/// <summary>Sets ViewBag properties needed by _KioskLayout from a Company entity.</summary>
private async Task PopulateKioskViewBag(Company company)
{
ViewBag.CompanyId = company.Id;
ViewBag.CompanyName = company.CompanyName;
ViewBag.CompanyLogoUrl = !string.IsNullOrEmpty(company.LogoFilePath)
? Url.Action("Logo", "Kiosk")
: null;
ViewBag.WelcomeUrl = "/Kiosk/Welcome";
// Pass the intake output setting so Terms.cshtml can show matching wording
var prefs = await _unitOfWork.CompanyPreferences.FirstOrDefaultAsync(
p => p.CompanyId == company.Id && !p.IsDeleted, ignoreQueryFilters: true);
ViewBag.KioskIntakeOutput = prefs?.KioskIntakeOutput ?? "Quote";
}
/// <summary>Loads the company from a session's CompanyId and populates ViewBag.</summary>
private async Task PopulateKioskViewBagFromSession(KioskSession session)
{
var company = await _unitOfWork.Companies.GetByIdAsync(session.CompanyId, ignoreQueryFilters: true);
if (company != null)
await PopulateKioskViewBag(company);
ViewBag.SessionToken = session.SessionToken;
ViewBag.SessionType = session.SessionType;
// Reset to Welcome screen after 45 s of inactivity on any intake step.
// The Welcome screen itself stays on indefinitely (no timeout override there).
ViewBag.InactivityTimeoutMs = 45_000;
}
}
@@ -9,6 +9,7 @@ using PowderCoating.Core.Interfaces;
using PowderCoating.Infrastructure.Data; using PowderCoating.Infrastructure.Data;
using PowderCoating.Shared.Constants; using PowderCoating.Shared.Constants;
using Stripe; using Stripe;
using AccountSubTypeEnum = PowderCoating.Core.Enums.AccountSubType;
namespace PowderCoating.Web.Controllers; namespace PowderCoating.Web.Controllers;
@@ -26,6 +27,7 @@ public class PaymentController : Controller
private readonly IInAppNotificationService _inApp; private readonly IInAppNotificationService _inApp;
private readonly IConfiguration _configuration; private readonly IConfiguration _configuration;
private readonly ILogger<PaymentController> _logger; private readonly ILogger<PaymentController> _logger;
private readonly IAccountBalanceService _accountBalanceService;
public PaymentController( public PaymentController(
ApplicationDbContext context, ApplicationDbContext context,
@@ -33,7 +35,8 @@ public class PaymentController : Controller
INotificationService notificationService, INotificationService notificationService,
IInAppNotificationService inApp, IInAppNotificationService inApp,
IConfiguration configuration, IConfiguration configuration,
ILogger<PaymentController> logger) ILogger<PaymentController> logger,
IAccountBalanceService accountBalanceService)
{ {
_context = context; _context = context;
_stripeConnect = stripeConnect; _stripeConnect = stripeConnect;
@@ -41,6 +44,7 @@ public class PaymentController : Controller
_inApp = inApp; _inApp = inApp;
_configuration = configuration; _configuration = configuration;
_logger = logger; _logger = logger;
_accountBalanceService = accountBalanceService;
} }
// ─── GET /pay/{token} ──────────────────────────────────────────────────── // ─── GET /pay/{token} ────────────────────────────────────────────────────
@@ -149,6 +153,86 @@ public class PaymentController : Controller
return Ok(new { clientSecret, surchargeAmount = surcharge }); return Ok(new { clientSecret, surchargeAmount = surcharge });
} }
// ─── GET /invoice/{token} ────────────────────────────────────────────────
/// <summary>
/// Customer-facing read-only invoice view page. Resolved via PublicViewToken (permanent, no expiry).
/// Shows full line items, totals, and company branding. If a valid PaymentLinkToken exists, renders
/// a "Pay Now" button linking to /pay/{paymentLinkToken}. This is the link sent in SMS messages
/// since SMS cannot attach a PDF.
/// </summary>
[HttpGet("/invoice/{token}")]
public async Task<IActionResult> InvoiceView(string token)
{
try
{
var invoice = await _context.Invoices
.AsNoTracking()
.Include(i => i.InvoiceItems)
.Include(i => i.Customer)
.Include(i => i.Job)
.IgnoreQueryFilters()
.FirstOrDefaultAsync(i => i.PublicViewToken == token && !i.IsDeleted);
if (invoice == null)
return View("PaymentError", "This invoice link is invalid or has been removed.");
var company = await _context.Companies.AsNoTracking()
.IgnoreQueryFilters()
.FirstOrDefaultAsync(c => c.Id == invoice.CompanyId && !c.IsDeleted);
if (company == null)
return View("PaymentError", "Unable to load invoice details.");
var paymentUrl = (!string.IsNullOrEmpty(invoice.PaymentLinkToken)
&& invoice.PaymentLinkExpiresAt > DateTime.UtcNow
&& invoice.BalanceDue > 0)
? $"{Request.Scheme}://{Request.Host}/pay/{invoice.PaymentLinkToken}"
: null;
var vm = new InvoiceViewViewModel
{
InvoiceNumber = invoice.InvoiceNumber,
InvoiceDate = invoice.InvoiceDate,
DueDate = invoice.DueDate,
CustomerName = invoice.Customer != null
? $"{invoice.Customer.ContactFirstName} {invoice.Customer.ContactLastName}".Trim()
: "Valued Customer",
CompanyName = company.CompanyName,
CompanyPhone = company.Phone,
CompanyAddress = string.Join(", ", new[] { company.Address, company.City, company.State, company.ZipCode }
.Where(s => !string.IsNullOrWhiteSpace(s))),
LogoFilePath = company.LogoFilePath,
SubTotal = invoice.SubTotal,
TaxPercent = invoice.TaxPercent,
TaxAmount = invoice.TaxAmount,
DiscountAmount = invoice.DiscountAmount,
Total = invoice.Total,
AmountPaid = invoice.AmountPaid,
BalanceDue = invoice.BalanceDue,
Status = invoice.Status,
Notes = invoice.Notes,
Terms = invoice.Terms,
JobNumber = invoice.Job?.JobNumber,
PaymentUrl = paymentUrl,
LineItems = invoice.InvoiceItems.Select(i => new InvoiceViewLineItem
{
Description = i.Description,
Quantity = i.Quantity,
UnitPrice = i.UnitPrice,
TotalPrice = i.TotalPrice
}).ToList()
};
return View(vm);
}
catch (Exception ex)
{
_logger.LogError(ex, "InvoiceView failed for token {Token}", token);
return View("PaymentError", "An error occurred loading this invoice.");
}
}
// ─── GET /pay/deposit/{token} ──────────────────────────────────────────── // ─── GET /pay/deposit/{token} ────────────────────────────────────────────
/// <summary> /// <summary>
@@ -378,8 +462,30 @@ public class PaymentController : Controller
invoice.UpdatedAt = DateTime.UtcNow; invoice.UpdatedAt = DateTime.UtcNow;
_context.Update(invoice); _context.Update(invoice);
// Create a Payment record so the payment appears in AR and bank reports, and make the
// matching GL entries. Manual payments go through RecordPayment which does the same thing;
// this makes Stripe payments consistent with that path.
var (arAcctId, checkingAcctId) = await GetGlAccountIdsAsync(invoice.CompanyId);
var stripePayment = new Core.Entities.Payment
{
InvoiceId = invoice.Id,
Amount = netPayment,
PaymentDate = DateTime.UtcNow,
PaymentMethod = PowderCoating.Core.Enums.PaymentMethod.CreditDebitCard,
Reference = intent.Id,
Notes = $"Online payment via Stripe. Surcharge: {surcharge:C}",
DepositAccountId = checkingAcctId,
CompanyId = invoice.CompanyId,
CreatedAt = DateTime.UtcNow
};
_context.Payments.Add(stripePayment);
await _context.SaveChangesAsync(); await _context.SaveChangesAsync();
await _accountBalanceService.DebitAsync(checkingAcctId, netPayment);
await _accountBalanceService.CreditAsync(arAcctId, netPayment);
_logger.LogInformation("Online payment of {Amount:C} received for invoice {InvoiceId}", amountPaidDollars, invoiceId); _logger.LogInformation("Online payment of {Amount:C} received for invoice {InvoiceId}", amountPaidDollars, invoiceId);
await _notificationService.NotifyOnlinePaymentReceivedAsync(invoice, netPayment, surcharge, intent.Id); await _notificationService.NotifyOnlinePaymentReceivedAsync(invoice, netPayment, surcharge, intent.Id);
@@ -553,6 +659,8 @@ public class PaymentController : Controller
var refundAmountDollars = latestRefund.Amount / 100m; var refundAmountDollars = latestRefund.Amount / 100m;
var (arAcctIdR, checkingAcctIdR) = await GetGlAccountIdsAsync(invoice.CompanyId);
var refund = new Core.Entities.Refund var refund = new Core.Entities.Refund
{ {
CompanyId = invoice.CompanyId, CompanyId = invoice.CompanyId,
@@ -565,6 +673,7 @@ public class PaymentController : Controller
Notes = $"Automatic refund via Stripe. PaymentIntent: {charge.PaymentIntentId}", Notes = $"Automatic refund via Stripe. PaymentIntent: {charge.PaymentIntentId}",
Status = Core.Enums.RefundStatus.Issued, Status = Core.Enums.RefundStatus.Issued,
IssuedDate = DateTime.UtcNow, IssuedDate = DateTime.UtcNow,
DepositAccountId = checkingAcctIdR,
CreatedAt = DateTime.UtcNow CreatedAt = DateTime.UtcNow
}; };
_context.Refunds.Add(refund); _context.Refunds.Add(refund);
@@ -588,6 +697,10 @@ public class PaymentController : Controller
_context.Update(invoice); _context.Update(invoice);
await _context.SaveChangesAsync(); await _context.SaveChangesAsync();
// GL: DR AR (customer owes again) / CR Checking (cash left the bank)
await _accountBalanceService.DebitAsync(arAcctIdR, refundAmountDollars);
await _accountBalanceService.CreditAsync(checkingAcctIdR, refundAmountDollars);
_logger.LogInformation("Refund of {Amount:C} recorded for invoice {InvoiceId} (Stripe refund {RefundId})", _logger.LogInformation("Refund of {Amount:C} recorded for invoice {InvoiceId} (Stripe refund {RefundId})",
refundAmountDollars, invoice.Id, latestRefund.Id); refundAmountDollars, invoice.Id, latestRefund.Id);
} }
@@ -652,6 +765,8 @@ public class PaymentController : Controller
if (alreadyRecorded) return; if (alreadyRecorded) return;
var amount = dispute.Amount / 100m; var amount = dispute.Amount / 100m;
var (arAcctIdD, checkingAcctIdD) = await GetGlAccountIdsAsync(invoice.CompanyId);
var refund = new Core.Entities.Refund var refund = new Core.Entities.Refund
{ {
CompanyId = invoice.CompanyId, CompanyId = invoice.CompanyId,
@@ -664,6 +779,7 @@ public class PaymentController : Controller
Notes = $"Automatic chargeback loss via Stripe. Dispute ID: {dispute.Id}", Notes = $"Automatic chargeback loss via Stripe. Dispute ID: {dispute.Id}",
Status = Core.Enums.RefundStatus.Issued, Status = Core.Enums.RefundStatus.Issued,
IssuedDate = DateTime.UtcNow, IssuedDate = DateTime.UtcNow,
DepositAccountId = checkingAcctIdD,
CreatedAt = DateTime.UtcNow CreatedAt = DateTime.UtcNow
}; };
_context.Refunds.Add(refund); _context.Refunds.Add(refund);
@@ -687,6 +803,9 @@ public class PaymentController : Controller
_context.Update(invoice); _context.Update(invoice);
await _context.SaveChangesAsync(); await _context.SaveChangesAsync();
await _accountBalanceService.DebitAsync(arAcctIdD, amount);
await _accountBalanceService.CreditAsync(checkingAcctIdD, amount);
_logger.LogWarning("Chargeback lost for invoice {InvoiceId}, {Amount:C} reversed", invoice.Id, amount); _logger.LogWarning("Chargeback lost for invoice {InvoiceId}, {Amount:C} reversed", invoice.Id, amount);
} }
} }
@@ -696,6 +815,27 @@ public class PaymentController : Controller
/// where the invoice ID is not in the Stripe metadata. <c>IgnoreQueryFilters</c> is required /// where the invoice ID is not in the Stripe metadata. <c>IgnoreQueryFilters</c> is required
/// because there is no authenticated tenant context in webhook handlers. /// because there is no authenticated tenant context in webhook handlers.
/// </summary> /// </summary>
/// <summary>
/// Resolves the primary AR and Checking/Cash account IDs for a company, used by webhook handlers
/// to make GL entries without an authenticated tenant context. Returns nulls gracefully so
/// IAccountBalanceService.DebitAsync/CreditAsync silently skips missing accounts.
/// </summary>
private async Task<(int? ArAccountId, int? CheckingAccountId)> GetGlAccountIdsAsync(int companyId)
{
var ar = await _context.Accounts
.Where(a => a.CompanyId == companyId && a.IsActive && !a.IsDeleted
&& a.AccountSubType == AccountSubTypeEnum.AccountsReceivable)
.Select(a => (int?)a.Id)
.FirstOrDefaultAsync();
var checking = await _context.Accounts
.Where(a => a.CompanyId == companyId && a.IsActive && !a.IsDeleted
&& (a.AccountSubType == AccountSubTypeEnum.Checking
|| a.AccountSubType == AccountSubTypeEnum.Cash))
.Select(a => (int?)a.Id)
.FirstOrDefaultAsync();
return (ar, checking);
}
private async Task<Core.Entities.Invoice?> FindInvoiceByPaymentIntentAsync(string? paymentIntentId) private async Task<Core.Entities.Invoice?> FindInvoiceByPaymentIntentAsync(string? paymentIntentId)
{ {
if (string.IsNullOrEmpty(paymentIntentId)) return null; if (string.IsNullOrEmpty(paymentIntentId)) return null;
@@ -837,6 +977,39 @@ public class DepositPaymentPageViewModel
public string StripeAccountId { get; set; } = string.Empty; public string StripeAccountId { get; set; } = string.Empty;
} }
public class InvoiceViewViewModel
{
public string InvoiceNumber { get; set; } = string.Empty;
public DateTime InvoiceDate { get; set; }
public DateTime? DueDate { get; set; }
public string CustomerName { get; set; } = string.Empty;
public string CompanyName { get; set; } = string.Empty;
public string? CompanyPhone { get; set; }
public string? CompanyAddress { get; set; }
public string? LogoFilePath { get; set; }
public decimal SubTotal { get; set; }
public decimal TaxPercent { get; set; }
public decimal TaxAmount { get; set; }
public decimal DiscountAmount { get; set; }
public decimal Total { get; set; }
public decimal AmountPaid { get; set; }
public decimal BalanceDue { get; set; }
public InvoiceStatus Status { get; set; }
public string? Notes { get; set; }
public string? Terms { get; set; }
public string? JobNumber { get; set; }
public string? PaymentUrl { get; set; }
public List<InvoiceViewLineItem> LineItems { get; set; } = new();
}
public class InvoiceViewLineItem
{
public string Description { get; set; } = string.Empty;
public decimal Quantity { get; set; }
public decimal UnitPrice { get; set; }
public decimal TotalPrice { get; set; }
}
public class CreateIntentRequest public class CreateIntentRequest
{ {
public decimal Amount { get; set; } public decimal Amount { get; set; }
@@ -9,6 +9,7 @@ namespace PowderCoating.Web.Controllers;
public class PlatformAdminController : Controller public class PlatformAdminController : Controller
{ {
private static readonly bool ShowRawLogFiles = string.IsNullOrEmpty(Environment.GetEnvironmentVariable("WEBSITE_SITE_NAME")); private static readonly bool ShowRawLogFiles = string.IsNullOrEmpty(Environment.GetEnvironmentVariable("WEBSITE_SITE_NAME"));
private static readonly bool ShowStorageMigration = string.IsNullOrEmpty(Environment.GetEnvironmentVariable("WEBSITE_SITE_NAME"));
public IActionResult TenantsBilling() => View(BuildTenantsBillingHub()); public IActionResult TenantsBilling() => View(BuildTenantsBillingHub());
@@ -47,7 +48,6 @@ public class PlatformAdminController : Controller
Card("Platform Users", "Manage SuperAdmin accounts and review platform-user access details.", "PlatformUsers", "Index", "bi-people-fill", "Daily", SubtleBadge("primary")), Card("Platform Users", "Manage SuperAdmin accounts and review platform-user access details.", "PlatformUsers", "Index", "bi-people-fill", "Daily", SubtleBadge("primary")),
Card("User Activity", "Review cross-tenant usage history, filters, and behavioral trends.", "UserActivity", "Index", "bi-person-lines-fill", "Review", SubtleBadge("success")), Card("User Activity", "Review cross-tenant usage history, filters, and behavioral trends.", "UserActivity", "Index", "bi-person-lines-fill", "Review", SubtleBadge("success")),
Card("Online Now", "Check who is currently active in the application right now.", "UserActivity", "Online", "bi-broadcast-pin", "Live", SubtleBadge("success")), Card("Online Now", "Check who is currently active in the application right now.", "UserActivity", "Online", "bi-broadcast-pin", "Live", SubtleBadge("success")),
Card("Onboarding Progress", "Track which companies are still working through setup and activation milestones.", "OnboardingProgress", "Index", "bi-rocket-takeoff", "Support", SubtleBadge("warning")),
Card("Platform Notifications", "Review platform-level in-app notifications and operational follow-ups.", "PlatformNotifications", "Index", "bi-bell", "Monitor", SubtleBadge("info")) Card("Platform Notifications", "Review platform-level in-app notifications and operational follow-ups.", "PlatformNotifications", "Index", "bi-bell", "Monitor", SubtleBadge("info"))
} }
}; };
@@ -94,21 +94,28 @@ public class PlatformAdminController : Controller
}; };
} }
private static PlatformAdminHubViewModel BuildMaintenanceHub() => new() private static PlatformAdminHubViewModel BuildMaintenanceHub()
{
var cards = new List<PlatformAdminLinkCardViewModel>
{
Card("Data Export", "Export a tenant company's data set for audits, offboarding, support, or migration work.", "DataExport", "Index", "bi-file-earmark-arrow-down", "Maintenance", SubtleBadge("warning")),
Card("Data Purge", "Permanently delete soft-deleted records after previewing impact and cutoff windows.", "DataPurge", "Index", "bi-trash3", "Dangerous", SubtleBadge("danger")),
Card("Seed Data", "Seed or remove system and demo data for setup, QA, or controlled test scenarios.", "SeedData", "Index", "bi-database-fill-gear", "Restricted", SubtleBadge("danger"))
};
if (ShowStorageMigration)
cards.Insert(2, Card("Storage Migration", "Run one-off migration of local media files into cloud storage.", "StorageMigration", "Index", "bi-cloud-upload", "One-off", SubtleBadge("info")));
return new PlatformAdminHubViewModel
{ {
Title = "Maintenance", Title = "Maintenance",
PageIcon = "bi-wrench-adjustable-circle", PageIcon = "bi-wrench-adjustable-circle",
Intro = "Use these tools for exceptional maintenance work, migration tasks, and destructive admin operations. They are not routine day-to-day workflows.", Intro = "Use these tools for exceptional maintenance work, migration tasks, and destructive admin operations. They are not routine day-to-day workflows.",
WarningTitle = "Use With Care", WarningTitle = "Use With Care",
WarningMessage = "These tools can expose bulk data, change platform state, or permanently remove records. Use them deliberately and preferably with a written reason or ticket.", WarningMessage = "These tools can expose bulk data, change platform state, or permanently remove records. Use them deliberately and preferably with a written reason or ticket.",
Cards = new List<PlatformAdminLinkCardViewModel> Cards = cards
{
Card("Data Export", "Export a tenant company's data set for audits, offboarding, support, or migration work.", "DataExport", "Index", "bi-file-earmark-arrow-down", "Maintenance", SubtleBadge("warning")),
Card("Data Purge", "Permanently delete soft-deleted records after previewing impact and cutoff windows.", "DataPurge", "Index", "bi-trash3", "Dangerous", SubtleBadge("danger")),
Card("Storage Migration", "Run one-off migration of local media files into cloud storage.", "StorageMigration", "Index", "bi-cloud-upload", "One-off", SubtleBadge("info")),
Card("Seed Data", "Seed or remove system and demo data for setup, QA, or controlled test scenarios.", "SeedData", "Index", "bi-database-fill-gear", "Restricted", SubtleBadge("danger"))
}
}; };
}
private static PlatformAdminLinkCardViewModel Card( private static PlatformAdminLinkCardViewModel Card(
string title, string title,
@@ -195,6 +195,10 @@ public class VendorCreditsController : Controller
foreach (var line in vc.LineItems) foreach (var line in vc.LineItems)
await _accountBalanceService.CreditAsync(line.AccountId, line.Amount); await _accountBalanceService.CreditAsync(line.AccountId, line.Amount);
// Record posting date so Void() can reverse only if GL entries were actually made.
vc.PostedDate = DateTime.UtcNow;
await _unitOfWork.VendorCredits.UpdateAsync(vc);
// Status stays Open — the credit is now in the GL but not yet applied to a bill // Status stays Open — the credit is now in the GL but not yet applied to a bill
await _unitOfWork.CompleteAsync(); await _unitOfWork.CompleteAsync();
}); });
@@ -260,6 +264,12 @@ public class VendorCreditsController : Controller
// ── Void ───────────────────────────────────────────────────────────────── // ── Void ─────────────────────────────────────────────────────────────────
/// <summary>
/// Voids a vendor credit. If the credit was previously posted (PostedDate is set), reverses the
/// original GL entries: CR Accounts Payable / DR each expense line item, restoring both balances.
/// Only the unapplied RemainingAmount of AP is reversed — applied portions reduced bill balances
/// that are already settled and remain part of the immutable audit trail.
/// </summary>
[HttpPost] [HttpPost]
[Authorize(Policy = AppConstants.Policies.CompanyAdminOnly)] [Authorize(Policy = AppConstants.Policies.CompanyAdminOnly)]
[ValidateAntiForgeryToken] [ValidateAntiForgeryToken]
@@ -267,7 +277,10 @@ public class VendorCreditsController : Controller
{ {
if (!AllowAccounting()) return RedirectToAction("Landing", "Reports"); if (!AllowAccounting()) return RedirectToAction("Landing", "Reports");
var vc = await _unitOfWork.VendorCredits.GetByIdAsync(id); var vc = (await _unitOfWork.VendorCredits.FindAsync(
v => v.Id == id, false,
v => v.LineItems))
.FirstOrDefault();
if (vc == null) return NotFound(); if (vc == null) return NotFound();
if (vc.Status == VendorCreditStatus.Applied) if (vc.Status == VendorCreditStatus.Applied)
@@ -276,9 +289,25 @@ public class VendorCreditsController : Controller
return RedirectToAction(nameof(Details), new { id }); return RedirectToAction(nameof(Details), new { id });
} }
await _unitOfWork.ExecuteInTransactionAsync(async () =>
{
// Reverse GL only if Post() was previously called; unposted credits have no GL entries.
if (vc.PostedDate.HasValue && vc.RemainingAmount > 0)
{
// CR AP for the unapplied amount (undoes the debit made at Post time)
await _accountBalanceService.CreditAsync(vc.APAccountId, vc.RemainingAmount);
// DR each expense line proportionally (unapplied fraction of each line)
var applyRatio = vc.Total > 0 ? vc.RemainingAmount / vc.Total : 1m;
foreach (var line in vc.LineItems)
await _accountBalanceService.DebitAsync(line.AccountId, line.Amount * applyRatio);
}
vc.Status = VendorCreditStatus.Voided; vc.Status = VendorCreditStatus.Voided;
vc.RemainingAmount = 0; vc.RemainingAmount = 0;
await _unitOfWork.VendorCredits.UpdateAsync(vc);
await _unitOfWork.CompleteAsync(); await _unitOfWork.CompleteAsync();
});
TempData["Success"] = $"Vendor credit {vc.CreditNumber} voided."; TempData["Success"] = $"Vendor credit {vc.CreditNumber} voided.";
return RedirectToAction(nameof(Index)); return RedirectToAction(nameof(Index));
@@ -109,6 +109,7 @@ public static class HelpKnowledgeBase
- Job Priority Board /JobsPriority - Job Priority Board /JobsPriority
- Online Payments /Invoices/OnlinePayments - Online Payments /Invoices/OnlinePayments
- Gift Certificates /GiftCertificates - Gift Certificates /GiftCertificates
- Intake Sessions /Kiosk/Intakes (walk-in and remote intake sessions submitted via the kiosk tablet)
**Inventory section:** **Inventory section:**
- Catalog Items /CatalogItems - Catalog Items /CatalogItems
@@ -1265,6 +1266,60 @@ public static class HelpKnowledgeBase
--- ---
## CUSTOMER INTAKE KIOSK
**Where:** Kiosk Setup [/Kiosk/Activate](/Kiosk/Activate) | Intake Sessions [/Kiosk/Intakes](/Kiosk/Intakes)
**What it does:** Lets walk-in customers fill out their own intake form on a front-desk tablet. On submission, a Customer record and either a Draft Quote or a Pending Job are auto-created (controlled by the Kiosk Output Setting), and staff receive an in-app notification. Also supports remote intake via email link so customers fill out the form on their own phone before arriving.
**Kiosk Output Setting (Company Settings Kiosk tab):**
- "Create a Quote" (default) creates a Draft quote on submission; terms shown to customer say "subject to a formal quote." Best for shops that price after seeing the parts.
- "Create a Job" creates a Pending job on submission; terms say "team member will reach out about pricing." Best for shops that price on the spot.
**Setup (one-time per device):**
1. Go to Settings Kiosk Setup (or /Kiosk/Activate)
2. Click Activate Kiosk generates a secure activation token and sets a device cookie (365-day lifespan)
3. On the tablet browser, navigate to /Kiosk/Welcome the tablet is now in kiosk mode
4. Add to Home Screen on iOS/Android for a full-screen PWA experience that preserves camera permissions
**Starting an in-person intake:**
1. Customer approaches the tablet it shows the Welcome screen with company logo and a green "Ready" dot
2. Staff member clicks "Start Intake" on the Dashboard (Kiosk card)
3. Tablet picks up the new session within 3 seconds and auto-navigates to the intake form
4. Customer completes 3 steps: Contact info Job description Terms & drawn signature
5. On submit: thank-you screen shown, kiosk returns to Welcome after 30 seconds
6. If idle for 45 seconds during any intake step, the form resets to the Welcome screen automatically
**Sending a remote intake link:**
- Click "Send Intake Link" on the Dashboard Kiosk card OR from /Kiosk/Intakes Send Intake Link
- Enter the customer's email they receive a link to complete the form on their own device
- Remote sessions use a checkbox agreement instead of a drawn signature
**What happens on submission:**
- Customer is matched by email (first), then phone; if no match, a new non-commercial customer is created
- A Draft Quote or Pending Job is created depending on the Kiosk Output Setting (see above)
- SMS opt-in updates the customer record with NotifyBySms = true and a TCPA-compliant consent timestamp
- In-app notification fires: "Walk-in Intake Submitted" (in-person) or "Remote Intake Submitted" (remote link) with a link to /Kiosk/Intakes
**Reviewing submissions (Intake Sessions page):**
- Filter tabs: All / Submitted / Pending / Expired
- Each row shows customer name, phone, email, job description snippet, session type badge, SMS opt-in icon
- "View Quote" button appears in Quote mode; opens the auto-created Draft quote for pricing and review
- "View Job" button appears in Job mode; opens the auto-created Pending job so staff can assign and progress it
- "Customer" button opens the matched/created customer record
- If submission failed (e.g. seed data not run), the session is still marked Submitted but buttons won't appear raw intake data is still visible so staff can create manually
**Dashboard Kiosk card:** Shows whether the kiosk is activated. Contains "Start Intake" (triggers in-person session) and "Send Intake Link" (opens email dialog) buttons. Both are disabled if the kiosk is not activated.
**Troubleshooting:**
- "Connection issue — retrying…" on tablet: Wi-Fi problem; dot auto-recovers when connectivity returns
- Tablet doesn't respond to Start Intake: waits up to 3 s; reload Welcome page if still stuck
- No View Quote/Job button after submission: Seed Data not run Platform Admin must run it from Platform Management Seed Data
- Signature pad not working: requires capacitive touch (finger or stylus); ensure "Request Desktop Site" is off in browser settings
- AI quote times out on mobile: photos are auto-compressed; "Still analyzing…" message appears after 30 s; retry on stronger connection
---
## COMMON WORKFLOWS ## COMMON WORKFLOWS
**New company first-time setup:** **New company first-time setup:**
@@ -1279,6 +1334,15 @@ public static class HelpKnowledgeBase
**Prospect to customer:** **Prospect to customer:**
Create Quote for prospect Quote Approved Convert Prospect to Customer Convert Quote to Job Create Quote for prospect Quote Approved Convert Prospect to Customer Convert Quote to Job
**Walk-in customer intake (kiosk Quote mode):**
Staff clicks "Start Intake" on Dashboard tablet navigates to intake form within 3 s customer fills out 3 steps (contact, job description, terms + signature) system creates Customer + Draft Quote "Walk-in Intake Submitted" notification fires staff reviews at /Kiosk/Intakes clicks "View Quote" to price and send the quote
**Walk-in customer intake (kiosk Job mode):**
Same flow as above, but system creates a Pending Job instead of a Quote staff clicks "View Job" to assign a worker and progress the job through the workflow
**Remote intake (customer fills out before arriving):**
Staff clicks "Send Intake Link" on Dashboard or Intakes page enters customer email customer receives link and completes form on their own device same auto-create flow as in-person; notification reads "Remote Intake Submitted"
**Walk-in / phone quote (quick estimate):** **Walk-in / phone quote (quick estimate):**
Click the AI Quick Quote button (dark-blue floating button, bottom-right) type description AI returns price estimate Save as draft under "Walk-In / Phone" open the quote reassign the Customer dropdown on Quote Details to the real customer record once you have their info Click the AI Quick Quote button (dark-blue floating button, bottom-right) type description AI returns price estimate Save as draft under "Walk-In / Phone" open the quote reassign the Customer dropdown on Quote Details to the real customer record once you have their info
+55
View File
@@ -0,0 +1,55 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.SignalR;
using Microsoft.Extensions.Logging;
namespace PowderCoating.Web.Hubs;
/// <summary>
/// SignalR hub that delivers "StartIntake" push events to the front-desk tablet.
/// Deliberately [AllowAnonymous] — the tablet runs without a logged-in user.
/// Security is enforced at the kiosk route level via the KioskActivationToken cookie.
///
/// On connect the tablet passes ?companyId=N in the hub URL query string; this hub
/// places that connection in the company-scoped group "kiosk-{companyId}" so that
/// KioskController.StartSession can push to exactly that company's tablet.
/// </summary>
[AllowAnonymous]
public class KioskHub : Hub
{
private readonly ILogger<KioskHub> _logger;
/// <summary>Initialises the hub with the required logger.</summary>
public KioskHub(ILogger<KioskHub> logger)
{
_logger = logger;
}
/// <summary>
/// Joins the connection to the company-scoped kiosk group on connect.
/// companyId is read from the ?companyId query param embedded in the hub URL by the Welcome view.
/// </summary>
public override async Task OnConnectedAsync()
{
try
{
var companyId = Context.GetHttpContext()?.Request.Query["companyId"].FirstOrDefault();
if (!string.IsNullOrEmpty(companyId))
await Groups.AddToGroupAsync(Context.ConnectionId, $"kiosk-{companyId}");
}
catch (Exception ex)
{
_logger.LogError(ex, "Error in KioskHub.OnConnectedAsync for connection {ConnectionId}", Context.ConnectionId);
}
await base.OnConnectedAsync();
}
/// <summary>Logs unexpected disconnects (e.g. tablet going to sleep).</summary>
public override async Task OnDisconnectedAsync(Exception? exception)
{
if (exception != null)
_logger.LogWarning(exception, "KioskHub client disconnected with error: {ConnectionId}", Context.ConnectionId);
await base.OnDisconnectedAsync(exception);
}
}
@@ -47,6 +47,8 @@ public class SubscriptionMiddleware
"/Billing", "/Billing",
"/api/", "/api/",
"/stripe/", "/stripe/",
"/hubs/",
"/Kiosk/",
"/Profile/Photo", "/Profile/Photo",
"/CompanyLogo", "/CompanyLogo",
"/AccountDataExport" "/AccountDataExport"
+7
View File
@@ -727,6 +727,12 @@ app.UseMiddleware<PowderCoating.Web.Middleware.MustChangePasswordMiddleware>();
// Track authenticated user presence (throttled, in-memory) // Track authenticated user presence (throttled, in-memory)
app.UseMiddleware<PowderCoating.Web.Middleware.OnlineUserMiddleware>(); app.UseMiddleware<PowderCoating.Web.Middleware.OnlineUserMiddleware>();
// Kiosk intake steps use /Kiosk/Intake/{token}/{action} so the token is a path segment
app.MapControllerRoute(
name: "kiosk_intake",
pattern: "Kiosk/Intake/{token}/{action}",
defaults: new { controller = "Kiosk" });
app.MapControllerRoute( app.MapControllerRoute(
name: "default", name: "default",
pattern: "{controller=Home}/{action=Index}/{id?}"); pattern: "{controller=Home}/{action=Index}/{id?}");
@@ -736,6 +742,7 @@ app.MapRazorPages();
// Map SignalR hubs // Map SignalR hubs
app.MapHub<PowderCoating.Web.Hubs.NotificationHub>("/hubs/notifications"); app.MapHub<PowderCoating.Web.Hubs.NotificationHub>("/hubs/notifications");
app.MapHub<PowderCoating.Web.Hubs.ShopHub>("/hubs/shop"); app.MapHub<PowderCoating.Web.Hubs.ShopHub>("/hubs/shop");
app.MapHub<PowderCoating.Web.Hubs.KioskHub>("/hubs/kiosk");
app.MapHealthChecks("/health"); app.MapHealthChecks("/health");
@@ -72,6 +72,7 @@ public class InAppNotificationService : IInAppNotificationService
message = notification.Message, message = notification.Message,
link = notification.Link, link = notification.Link,
notificationType = notification.NotificationType, notificationType = notification.NotificationType,
customerId = notification.CustomerId,
createdAt = now.ToString("o") createdAt = now.ToString("o")
}); });
} }
@@ -17,6 +17,11 @@
} }
<div class="container-fluid mt-3"> <div class="container-fluid mt-3">
<div class="mb-2">
<a asp-controller="PlatformAdmin" asp-action="Observability" class="text-muted small text-decoration-none">
<i class="bi bi-arrow-left me-1"></i>Observability
</a>
</div>
<div class="d-flex align-items-center justify-content-between mb-4"> <div class="d-flex align-items-center justify-content-between mb-4">
<div> <div>
<h4 class="mb-0"><i class="bi bi-robot me-2 text-primary"></i>AI Usage Report</h4> <h4 class="mb-0"><i class="bi bi-robot me-2 text-primary"></i>AI Usage Report</h4>
@@ -37,6 +37,11 @@
} }
<div class="container-fluid py-3"> <div class="container-fluid py-3">
<div class="mb-2">
<a asp-controller="PlatformAdmin" asp-action="Observability" class="text-muted small text-decoration-none">
<i class="bi bi-arrow-left me-1"></i>Observability
</a>
</div>
<div class="d-flex align-items-center justify-content-between mb-3"> <div class="d-flex align-items-center justify-content-between mb-3">
<div> <div>
<h4 class="mb-0"><i class="bi bi-shield-check me-2 text-primary"></i>Audit Log</h4> <h4 class="mb-0"><i class="bi bi-shield-check me-2 text-primary"></i>Audit Log</h4>
@@ -8,6 +8,11 @@
} }
<div class="container-fluid py-4"> <div class="container-fluid py-4">
<div class="mb-2">
<a asp-controller="PlatformAdmin" asp-action="Observability" class="text-muted small text-decoration-none">
<i class="bi bi-arrow-left me-1"></i>Observability
</a>
</div>
@* Add new ban form *@ @* Add new ban form *@
<div class="card shadow-sm mb-4"> <div class="card shadow-sm mb-4">
File diff suppressed because it is too large Load Diff
@@ -96,6 +96,7 @@
Plan <i class="bi @SortIcon("Plan")"></i> Plan <i class="bi @SortIcon("Plan")"></i>
</a> </a>
</th> </th>
<th>Health</th>
<th>Users</th> <th>Users</th>
<th>Setup Wizard</th> <th>Setup Wizard</th>
<th> <th>
@@ -138,6 +139,18 @@
<td> <td>
<span class="badge @PlanBadge(company.SubscriptionPlan)">@PlanName(company.SubscriptionPlan)</span> <span class="badge @PlanBadge(company.SubscriptionPlan)">@PlanName(company.SubscriptionPlan)</span>
</td> </td>
<td>
@{
var (hBadge, hLabel) = company.HealthRisk switch {
"Healthy" => ("bg-success-subtle text-success-emphasis border border-success-subtle", "Healthy"),
"AtRisk" => ("bg-warning-subtle text-warning-emphasis border border-warning-subtle", "At Risk"),
"Critical" => ("bg-danger-subtle text-danger-emphasis border border-danger-subtle", "Critical"),
"NeverActivated" => ("bg-secondary-subtle text-secondary-emphasis border border-secondary-subtle", "Never Active"),
_ => ("bg-secondary-subtle text-secondary-emphasis border border-secondary-subtle", company.HealthRisk)
};
}
<span class="badge @hBadge" title="Score: @company.HealthScore">@hLabel</span>
</td>
<td> <td>
<span class="badge bg-primary rounded-pill">@company.UserCount</span> <span class="badge bg-primary rounded-pill">@company.UserCount</span>
</td> </td>
@@ -34,6 +34,7 @@
<option value="data-retention">Data Retention</option> <option value="data-retention">Data Retention</option>
<option value="data-lookups">Data Lookups</option> <option value="data-lookups">Data Lookups</option>
<option value="pdf-templates">PDF Templates</option> <option value="pdf-templates">PDF Templates</option>
<option value="kiosk">Kiosk</option>
</select> </select>
</div> </div>
@@ -100,6 +101,11 @@
</button> </button>
</li> </li>
} }
<li class="nav-item" role="presentation">
<button class="nav-link" id="kiosk-tab" data-bs-toggle="tab" data-bs-target="#kiosk" type="button" role="tab">
<i class="bi bi-tablet"></i> Kiosk
</button>
</li>
</ul> </ul>
<!-- Tabs Content --> <!-- Tabs Content -->
@@ -1978,6 +1984,67 @@
</div> </div>
</div> </div>
} }
<!-- Kiosk Tab -->
<div class="tab-pane fade" id="kiosk" role="tabpanel">
<div class="card mt-3">
<div class="card-header">
<h5 class="mb-0"><i class="bi bi-tablet me-2"></i>Customer Intake Kiosk</h5>
</div>
<div class="card-body">
<h6 class="fw-semibold mb-1">Intake Output</h6>
<p class="text-muted small mb-3">
When a customer completes the intake form, what should be created in the system?
</p>
<div class="row g-3 mb-4">
<div class="col-md-6">
<div class="card h-100 border @(Model.Preferences?.KioskIntakeOutput == "Job" ? "" : "border-primary bg-primary-subtle")"
id="kioskOutputQuoteCard" style="cursor:pointer;" onclick="selectKioskOutput('Quote')">
<div class="card-body">
<div class="d-flex align-items-center gap-2 mb-2">
<div class="form-check mb-0">
<input class="form-check-input" type="radio" name="kioskOutput" id="kioskOutputQuote"
value="Quote" @(Model.Preferences?.KioskIntakeOutput != "Job" ? "checked" : "") />
</div>
<h6 class="mb-0 fw-semibold"><i class="bi bi-file-earmark-text me-1 text-primary"></i>Create a Quote</h6>
</div>
<p class="text-muted small mb-0">
A draft quote is created and reviewed by staff before work begins.
Best for shops that price after seeing the parts.
</p>
</div>
</div>
</div>
<div class="col-md-6">
<div class="card h-100 border @(Model.Preferences?.KioskIntakeOutput == "Job" ? "border-success bg-success-subtle" : "")"
id="kioskOutputJobCard" style="cursor:pointer;" onclick="selectKioskOutput('Job')">
<div class="card-body">
<div class="d-flex align-items-center gap-2 mb-2">
<div class="form-check mb-0">
<input class="form-check-input" type="radio" name="kioskOutput" id="kioskOutputJob"
value="Job" @(Model.Preferences?.KioskIntakeOutput == "Job" ? "checked" : "") />
</div>
<h6 class="mb-0 fw-semibold"><i class="bi bi-briefcase me-1 text-success"></i>Create a Job</h6>
</div>
<p class="text-muted small mb-0">
A job is created immediately on submission.
Best for shops that price on the spot and want the work order ready right away.
</p>
</div>
</div>
</div>
</div>
<button type="button" class="btn btn-primary" onclick="saveKioskSettings()">
<i class="bi bi-floppy me-1"></i> Save Kiosk Settings
</button>
</div>
</div>
</div>
</div> </div>
</div> </div>
@@ -3248,12 +3315,41 @@
else showError(data.message); else showError(data.message);
} }
function selectKioskOutput(value) {
document.getElementById('kioskOutputQuote').checked = value === 'Quote';
document.getElementById('kioskOutputJob').checked = value === 'Job';
document.getElementById('kioskOutputQuoteCard').classList.toggle('border-primary', value === 'Quote');
document.getElementById('kioskOutputQuoteCard').classList.toggle('bg-primary-subtle', value === 'Quote');
document.getElementById('kioskOutputJobCard').classList.toggle('border-success', value === 'Job');
document.getElementById('kioskOutputJobCard').classList.toggle('bg-success-subtle', value === 'Job');
}
async function saveKioskSettings() {
const value = document.querySelector('input[name="kioskOutput"]:checked')?.value ?? 'Quote';
const resp = await fetch('/CompanySettings/UpdateKioskSettings', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'RequestVerificationToken': $('input[name="__RequestVerificationToken"]').val()
},
body: JSON.stringify({ kioskIntakeOutput: value })
});
const data = await resp.json();
if (data.success) showSuccess(data.message);
else showError(data.message);
}
// Auto-open online-payments tab if redirected with ?tab=online-payments // Auto-open online-payments tab if redirected with ?tab=online-payments
const urlParams = new URLSearchParams(window.location.search); const urlParams = new URLSearchParams(window.location.search);
if (urlParams.get('tab') === 'online-payments') { if (urlParams.get('tab') === 'online-payments') {
const btn = document.querySelector('[data-bs-target="#online-payments"]'); const btn = document.querySelector('[data-bs-target="#online-payments"]');
if (btn) new bootstrap.Tab(btn).show(); if (btn) new bootstrap.Tab(btn).show();
} }
if (urlParams.get('tab') === 'kiosk') {
const btn = document.querySelector('[data-bs-target="#kiosk"]');
if (btn) new bootstrap.Tab(btn).show();
}
</script> </script>
} }
@@ -173,9 +173,11 @@
<i class="bi bi-envelope-slash me-1"></i>Email off <i class="bi bi-envelope-slash me-1"></i>Email off
</span> </span>
} }
<span id="sms-status-section">
@if (Model.NotifyBySms) @if (Model.NotifyBySms)
{ {
<span class="badge bg-success bg-opacity-10 text-success border border-success border-opacity-25"> <span class="badge bg-success bg-opacity-10 text-success border border-success border-opacity-25"
title="@(Model.SmsConsentedAt.HasValue ? "Consented " + Model.SmsConsentedAt.Value.ToLocalTime().ToString("MM/dd/yyyy") : "")">
<i class="bi bi-chat-fill me-1"></i>SMS on <i class="bi bi-chat-fill me-1"></i>SMS on
</span> </span>
} }
@@ -184,7 +186,22 @@
<span class="badge bg-secondary bg-opacity-10 text-secondary border border-secondary border-opacity-25"> <span class="badge bg-secondary bg-opacity-10 text-secondary border border-secondary border-opacity-25">
<i class="bi bi-chat-slash me-1"></i>SMS off <i class="bi bi-chat-slash me-1"></i>SMS off
</span> </span>
<button type="button" id="btnGetSmsConsent"
class="badge bg-primary bg-opacity-10 text-primary border border-primary border-opacity-25 border-0"
style="cursor:pointer;"
title="Send SMS consent form to the front-desk kiosk tablet"
onclick="pushSmsConsent(@Model.Id)">
<i class="bi bi-chat-dots me-1"></i>Get SMS Consent
</button>
<button type="button" id="btnCancelSmsConsent"
class="badge bg-warning bg-opacity-10 text-warning border border-warning border-opacity-25 border-0 d-none"
style="cursor:pointer;"
title="Cancel the pending kiosk consent request"
onclick="cancelSmsConsent()">
<i class="bi bi-x-circle me-1"></i>Cancel Consent
</button>
} }
</span>
</div> </div>
</div> </div>
</div> </div>
@@ -543,3 +560,8 @@
</div> </div>
</div> </div>
} }
@section Scripts {
<script src="~/js/customer-details.js" asp-append-version="true"></script>
}
@@ -33,6 +33,17 @@
</p> </p>
<div class="d-flex gap-2 flex-wrap align-items-center"> <div class="d-flex gap-2 flex-wrap align-items-center">
<a asp-controller="Jobs" asp-action="Board" class="btn btn-sm btn-primary">Open Jobs Board</a> <a asp-controller="Jobs" asp-action="Board" class="btn btn-sm btn-primary">Open Jobs Board</a>
@if (ViewBag.KioskActivated == true)
{
<button type="button" class="btn btn-sm btn-outline-info" id="btnStartIntake"
title="Push the intake form to the front-desk tablet">
<i class="bi bi-tablet me-1"></i>Start Intake
</button>
}
<a href="/Kiosk/SendRemoteLink" class="btn btn-sm btn-outline-secondary"
title="Email a customer a link to fill out the intake form remotely">
<i class="bi bi-envelope-at me-1"></i>Send Intake Link
</a>
@if (!string.IsNullOrEmpty(Model.TipOfTheDay)) @if (!string.IsNullOrEmpty(Model.TipOfTheDay))
{ {
<span class="text-muted d-none d-xl-inline" style="font-size:0.73rem;"><i class="bi bi-lightbulb me-1"></i>@Model.TipOfTheDay</span> <span class="text-muted d-none d-xl-inline" style="font-size:0.73rem;"><i class="bi bi-lightbulb me-1"></i>@Model.TipOfTheDay</span>
@@ -827,6 +838,40 @@
@section Scripts { @section Scripts {
<script src="~/js/shop-progress-widget.js" asp-append-version="true"></script> <script src="~/js/shop-progress-widget.js" asp-append-version="true"></script>
<script> <script>
// Start Intake — pushes SignalR event to front-desk tablet
document.getElementById('btnStartIntake')?.addEventListener('click', async function () {
const btn = this;
const token = document.querySelector('input[name="__RequestVerificationToken"]')?.value;
btn.disabled = true;
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>Sending…';
try {
const res = await fetch('/Kiosk/StartSession', {
method: 'POST',
headers: { 'RequestVerificationToken': token, 'Content-Type': 'application/json' }
});
const data = await res.json();
if (data.success) {
btn.innerHTML = '<i class="bi bi-check-circle me-1"></i>Sent!';
btn.classList.replace('btn-outline-info', 'btn-success');
setTimeout(() => {
btn.disabled = false;
btn.innerHTML = '<i class="bi bi-tablet me-1"></i>Start Intake';
btn.classList.replace('btn-success', 'btn-outline-info');
}, 3000);
} else {
throw new Error('Server returned failure');
}
} catch (err) {
btn.innerHTML = '<i class="bi bi-exclamation-triangle me-1"></i>Failed';
btn.classList.replace('btn-outline-info', 'btn-outline-danger');
setTimeout(() => {
btn.disabled = false;
btn.innerHTML = '<i class="bi bi-tablet me-1"></i>Start Intake';
btn.classList.replace('btn-outline-danger', 'btn-outline-info');
}, 3000);
}
});
// Powder Orders - Mark as Ordered // Powder Orders - Mark as Ordered
document.querySelectorAll('.mark-ordered-btn').forEach(btn => { document.querySelectorAll('.mark-ordered-btn').forEach(btn => {
btn.addEventListener('click', async function () { btn.addEventListener('click', async function () {
@@ -29,6 +29,11 @@
} }
<div class="container-fluid py-3"> <div class="container-fluid py-3">
<div class="mb-2">
<a asp-controller="PlatformAdmin" asp-action="Maintenance" class="text-muted small text-decoration-none">
<i class="bi bi-arrow-left me-1"></i>Maintenance
</a>
</div>
<div class="d-flex align-items-center justify-content-between mb-3"> <div class="d-flex align-items-center justify-content-between mb-3">
<div> <div>
<h4 class="mb-0"><i class="bi bi-file-earmark-arrow-down me-2 text-primary"></i>Data Export</h4> <h4 class="mb-0"><i class="bi bi-file-earmark-arrow-down me-2 text-primary"></i>Data Export</h4>
@@ -34,6 +34,11 @@
} }
<div class="container-fluid py-3"> <div class="container-fluid py-3">
<div class="mb-2">
<a asp-controller="PlatformAdmin" asp-action="Maintenance" class="text-muted small text-decoration-none">
<i class="bi bi-arrow-left me-1"></i>Maintenance
</a>
</div>
<div class="d-flex align-items-center justify-content-between mb-3"> <div class="d-flex align-items-center justify-content-between mb-3">
<div> <div>
<h4 class="mb-0"><i class="bi bi-trash3 me-2 text-danger"></i>Data Purge &amp; Cleanup</h4> <h4 class="mb-0"><i class="bi bi-trash3 me-2 text-danger"></i>Data Purge &amp; Cleanup</h4>
@@ -5,6 +5,11 @@
} }
<div class="container mt-4"> <div class="container mt-4">
<div class="mb-2">
<a asp-controller="PlatformAdmin" asp-action="Observability" class="text-muted small text-decoration-none">
<i class="bi bi-arrow-left me-1"></i>Observability
</a>
</div>
<div class="row mt-4"> <div class="row mt-4">
<div class="col-md-6"> <div class="col-md-6">
@@ -0,0 +1,209 @@
@{
ViewData["Title"] = "Customer Intake Kiosk";
}
<div class="d-flex align-items-center gap-2 mb-3">
<a asp-controller="Help" asp-action="Index" class="btn btn-sm btn-outline-secondary"><i class="bi bi-arrow-left"></i></a>
<nav aria-label="breadcrumb">
<ol class="breadcrumb mb-0">
<li class="breadcrumb-item"><a asp-controller="Help" asp-action="Index">Help</a></li>
<li class="breadcrumb-item active">Customer Intake Kiosk</li>
</ol>
</nav>
</div>
<div class="row g-4">
<div class="col-lg-9">
<section id="overview" class="mb-5">
<h2 class="h4 fw-bold border-bottom pb-2 mb-3">
<i class="bi bi-info-circle text-primary me-2"></i>Overview
</h2>
<p>
The Customer Intake Kiosk lets walk-in customers fill out their own intake form on a front-desk tablet
— no staff assistance required. When they're done, a <strong>customer record</strong> is automatically
created (or matched to an existing one), a <strong>Draft Quote or Pending Job</strong> is created
depending on your setting, and your team receives an in-app notification.
</p>
<p>
The kiosk runs as a browser page (optimised for iPad and Android tablets) and can also send a
<strong>remote link</strong> so customers fill out the form on their own phone before they arrive.
</p>
</section>
<section id="setup" class="mb-5">
<h2 class="h4 fw-bold border-bottom pb-2 mb-3">
<i class="bi bi-gear text-primary me-2"></i>Setting Up the Kiosk
</h2>
<ol>
<li class="mb-2">
Go to <strong>Settings → Kiosk Setup</strong> (or <a href="/Kiosk/Activate">/Kiosk/Activate</a>).
</li>
<li class="mb-2">
Click <strong>Activate Kiosk</strong>. This generates a unique activation token for your company
and sets a secure cookie on the current device.
</li>
<li class="mb-2">
On the tablet, open a browser and navigate to <code>/Kiosk/Welcome</code>. You'll see your
company logo and a "Ready" indicator — the tablet is now in kiosk mode.
</li>
<li class="mb-2">
<strong>Add to Home Screen</strong> on iOS/Android for a full-screen, app-like experience that
also preserves camera permissions between sessions.
</li>
</ol>
<div class="alert alert-info alert-permanent">
<i class="bi bi-info-circle me-2"></i>
The kiosk cookie is device-specific and lasts 365 days. If you swap tablets or clear the browser,
go back to Kiosk Setup and activate again.
</div>
</section>
<section id="starting" class="mb-5">
<h2 class="h4 fw-bold border-bottom pb-2 mb-3">
<i class="bi bi-play-circle text-primary me-2"></i>Starting an Intake Session
</h2>
<p>There are two ways to start an intake:</p>
<h3 class="h6 fw-semibold mt-3 mb-2">In-Person (tablet at front desk)</h3>
<ol>
<li class="mb-1">The tablet sits on the Welcome screen — the customer sees your logo and a "Ready" status dot.</li>
<li class="mb-1">A staff member clicks <strong>Start Intake</strong> on the Dashboard (in the Kiosk card).</li>
<li class="mb-1">The tablet detects the new session within 3 seconds and automatically navigates to the intake form.</li>
<li class="mb-1">The customer fills out <strong>3 steps</strong>: Contact info → Job description → Terms &amp; signature.</li>
<li class="mb-1">On Submit, the kiosk shows a thank-you screen and returns to Welcome after 30 seconds.</li>
</ol>
<div class="alert alert-warning alert-permanent mt-2">
<i class="bi bi-clock me-2"></i>
If the customer leaves the form untouched for <strong>45 seconds</strong>, it automatically
resets to the Welcome screen.
</div>
<h3 class="h6 fw-semibold mt-4 mb-2">Remote Link (customer fills out on their phone)</h3>
<ol>
<li class="mb-1">Go to <a href="/Kiosk/Intakes">Kiosk → Customer Intakes</a> and click <strong>Send Intake Link</strong>.</li>
<li class="mb-1">Or use the <strong>Send Intake Link</strong> button on the Dashboard Kiosk card.</li>
<li class="mb-1">Enter the customer's email address and send.</li>
<li class="mb-1">The customer receives an email with a secure link and completes the same 3-step form on their own device.</li>
<li class="mb-1">Remote sessions don't require a drawn signature — a checkbox agreement is used instead.</li>
</ol>
</section>
<section id="output-setting" class="mb-5">
<h2 class="h4 fw-bold border-bottom pb-2 mb-3">
<i class="bi bi-sliders text-primary me-2"></i>Kiosk Output Setting
</h2>
<p>
You can control what gets created when a customer submits the intake form.
Go to <a href="/CompanySettings?tab=kiosk">Company Settings → Kiosk</a> and choose:
</p>
<ul>
<li>
<strong>Create a Quote</strong> (default) — a Draft quote is created for staff to review and price
before work begins. The terms shown to the customer will say "subject to a formal quote." Use this
if you price after seeing the parts.
</li>
<li>
<strong>Create a Job</strong> — a Pending job is created immediately. The terms will say "a team
member will reach out about pricing." Use this if you price on the spot and want the work order
ready right away.
</li>
</ul>
</section>
<section id="what-happens" class="mb-5">
<h2 class="h4 fw-bold border-bottom pb-2 mb-3">
<i class="bi bi-arrow-right-circle text-primary me-2"></i>What Happens on Submission
</h2>
<p>When a customer submits their intake form, the system automatically:</p>
<ul>
<li><strong>Matches or creates a Customer</strong> — searches by email first, then phone. If no match, a new non-commercial customer record is created.</li>
<li>
<strong>Creates a Draft Quote or Pending Job</strong> — depending on your
<a href="/CompanySettings?tab=kiosk">Kiosk Output Setting</a>. Quote mode creates a Draft quote
(Normal priority); Job mode creates a Pending job with the customer's description and intake source
in Special Instructions.
</li>
<li><strong>Applies SMS consent</strong> — if the customer opted in, their customer record is updated with <code>NotifyBySms = true</code> and the consent timestamp (TCPA-compliant).</li>
<li>
<strong>Fires an in-app notification</strong> — your team's notification bell shows
"Walk-in Intake Submitted" (or "Remote Intake Submitted" for remote sessions) with a link to
the Intakes page.
</li>
</ul>
</section>
<section id="reviewing" class="mb-5">
<h2 class="h4 fw-bold border-bottom pb-2 mb-3">
<i class="bi bi-clipboard-check text-primary me-2"></i>Reviewing Submissions (Staff)
</h2>
<p>
Go to <a href="/Kiosk/Intakes">Operations → Intake Sessions</a> to see all sessions.
Filter by <strong>Submitted</strong>, <strong>Pending</strong>, or <strong>Expired</strong>.
</p>
<p>Each row shows:</p>
<ul>
<li>Customer name, phone, and email</li>
<li>Job description snippet</li>
<li>Session type (In-Person or Remote) and status badge</li>
<li>SMS opt-in indicator</li>
<li><strong>View Quote</strong> button — appears when the kiosk is set to Quote mode; opens the auto-created draft quote</li>
<li><strong>View Job</strong> button — appears when the kiosk is set to Job mode; opens the auto-created job</li>
<li><strong>Customer</strong> button — opens the matched or created customer record</li>
</ul>
<div class="alert alert-info alert-permanent">
<i class="bi bi-info-circle me-2"></i>
If submission failed (e.g. a configuration issue), the session is still marked Submitted but the
action buttons won't appear. The raw intake data (name, phone, description) is still
visible so staff can create the record manually.
</div>
</section>
<section id="troubleshooting" class="mb-5">
<h2 class="h4 fw-bold border-bottom pb-2 mb-3">
<i class="bi bi-exclamation-triangle text-primary me-2"></i>Troubleshooting
</h2>
<dl>
<dt>Kiosk Welcome screen shows "Connection issue — retrying…"</dt>
<dd class="mb-3">The tablet can't reach the server. Check the tablet's Wi-Fi connection. Once connectivity is restored the status dot automatically turns green — no refresh needed.</dd>
<dt>Kiosk doesn't respond when staff clicks Start Intake</dt>
<dd class="mb-3">The tablet polls every 3 seconds. Wait up to 3 seconds after clicking Start Intake. If it still doesn't respond, reload the Welcome page on the tablet. Make sure the tablet is on the same domain as the server (use HTTPS).</dd>
<dt>The tablet shows the wrong company logo or no logo</dt>
<dd class="mb-3">Upload your company logo at Settings → Company Settings → Logo. The kiosk reads your logo directly — no separate kiosk logo setting is needed.</dd>
<dt>Signature pad doesn't work on the tablet</dt>
<dd class="mb-3">Use a capacitive stylus or fingertip — the signature pad requires touch input. Make sure the browser isn't in desktop mode (check "Request Desktop Site" is off). The signature is only required for In-Person sessions.</dd>
<dt>Submission fails — no job or customer created</dt>
<dd class="mb-3">This usually means Seed Data hasn't been run for your company. Ask your administrator to go to Platform Management → Seed Data and run the seed. This creates the required job status and priority lookup rows.</dd>
<dt>AI quote on the quote wizard times out on mobile</dt>
<dd class="mb-3">Photos are automatically compressed before upload. If it still times out, your connection may be slow — the spinner will say "Still analyzing…" if it's taking longer than 30 seconds. Try again on a stronger connection.</dd>
</dl>
</section>
</div>
<div class="col-lg-3">
@await Html.PartialAsync("_HelpNav")
<div class="card border-0 shadow-sm">
<div class="card-header bg-transparent fw-semibold small text-muted text-uppercase">
On This Page
</div>
<div class="card-body p-0">
<nav class="nav flex-column small">
<a class="nav-link py-1 px-3" href="#overview">Overview</a>
<a class="nav-link py-1 px-3" href="#setup">Setting Up the Kiosk</a>
<a class="nav-link py-1 px-3" href="#starting">Starting an Intake</a>
<a class="nav-link py-1 px-3" href="#output-setting">Kiosk Output Setting</a>
<a class="nav-link py-1 px-3" href="#what-happens">What Happens on Submission</a>
<a class="nav-link py-1 px-3" href="#reviewing">Reviewing Submissions</a>
<a class="nav-link py-1 px-3" href="#troubleshooting">Troubleshooting</a>
</nav>
</div>
</div>
</div>
</div>
@@ -25,6 +25,10 @@
asp-controller="Help" asp-action="Jobs"> asp-controller="Help" asp-action="Jobs">
<i class="bi bi-briefcase"></i> Jobs <i class="bi bi-briefcase"></i> Jobs
</a> </a>
<a class="nav-link py-2 px-3 d-flex align-items-center gap-2 @(currentAction == "CustomerIntakeKiosk" ? "active fw-semibold text-primary" : "text-body")"
asp-controller="Help" asp-action="CustomerIntakeKiosk">
<i class="bi bi-tablet"></i> Customer Intake Kiosk
</a>
<a class="nav-link py-2 px-3 d-flex align-items-center gap-2 @(currentAction == "Quotes" ? "active fw-semibold text-primary" : "text-body")" <a class="nav-link py-2 px-3 d-flex align-items-center gap-2 @(currentAction == "Quotes" ? "active fw-semibold text-primary" : "text-body")"
asp-controller="Help" asp-action="Quotes"> asp-controller="Help" asp-action="Quotes">
<i class="bi bi-file-earmark-text"></i> Quotes <i class="bi bi-file-earmark-text"></i> Quotes
@@ -15,6 +15,10 @@
var canResend = !isDraft && !isVoided && Model.Status != InvoiceStatus.Paid; var canResend = !isDraft && !isVoided && Model.Status != InvoiceStatus.Paid;
var hasEmail = !string.IsNullOrWhiteSpace(Model.CustomerEmail); var hasEmail = !string.IsNullOrWhiteSpace(Model.CustomerEmail);
var emailOptedOut = hasEmail && !Model.CustomerNotifyByEmail; var emailOptedOut = hasEmail && !Model.CustomerNotifyByEmail;
var smsPhone = !string.IsNullOrWhiteSpace(Model.CustomerMobilePhone) ? Model.CustomerMobilePhone : Model.CustomerPhone;
var hasSms = !string.IsNullOrWhiteSpace(smsPhone) && Model.CustomerNotifyBySms;
var showSendModal = hasEmail && !emailOptedOut && hasSms; // both channels — show choice modal
var directSendSms = !hasEmail && hasSms; // SMS only — skip modal
var hasAvailableCredits = ViewBag.AvailableCreditMemos != null && ((IEnumerable<Microsoft.AspNetCore.Mvc.Rendering.SelectListItem>)ViewBag.AvailableCreditMemos).Any(); var hasAvailableCredits = ViewBag.AvailableCreditMemos != null && ((IEnumerable<Microsoft.AspNetCore.Mvc.Rendering.SelectListItem>)ViewBag.AvailableCreditMemos).Any();
var canIssueRefund = !isDraft && !isVoided && Model.AmountPaid > 0; var canIssueRefund = !isDraft && !isVoided && Model.AmountPaid > 0;
var canApplyCredit = !isVoided && Model.BalanceDue > 0 && hasAvailableCredits; var canApplyCredit = !isVoided && Model.BalanceDue > 0 && hasAvailableCredits;
@@ -579,14 +583,32 @@
<form id="sendInvoiceForm" asp-action="Send" asp-route-id="@Model.Id" method="post"> <form id="sendInvoiceForm" asp-action="Send" asp-route-id="@Model.Id" method="post">
@Html.AntiForgeryToken() @Html.AntiForgeryToken()
<input type="hidden" name="overrideEmail" id="sendInvoiceOverrideEmail" value="" /> <input type="hidden" name="overrideEmail" id="sendInvoiceOverrideEmail" value="" />
@if (emailOptedOut) <input type="hidden" name="sendEmail" id="sendInvoiceSendEmail" value="true" />
<input type="hidden" name="sendSms" id="sendInvoiceSendSms" value="false" />
@if (emailOptedOut && !hasSms)
{ {
<button type="button" class="btn btn-primary w-100" disabled <button type="button" class="btn btn-primary w-100" disabled
title="Email notifications are turned off for this customer"> title="No delivery channel available for this customer">
<i class="bi bi-send me-2"></i>Send Invoice <i class="bi bi-send me-2"></i>Send Invoice
</button> </button>
} }
else if (hasEmail) else if (showSendModal)
{
@* Both email + SMS available — let staff choose *@
<button type="button" class="btn btn-primary w-100"
data-bs-toggle="modal" data-bs-target="#sendChannelModal">
<i class="bi bi-send me-2"></i>Send Invoice
</button>
}
else if (directSendSms)
{
@* SMS only — send directly *@
<button type="button" class="btn btn-primary w-100"
onclick="submitSendInvoice(false, true)">
<i class="bi bi-send me-2"></i>Send Invoice via SMS
</button>
}
else if (hasEmail && !emailOptedOut)
{ {
<button type="button" class="btn btn-primary w-100" <button type="button" class="btn btn-primary w-100"
data-bs-toggle="modal" data-bs-target="#sendInvoiceModal"> data-bs-toggle="modal" data-bs-target="#sendInvoiceModal">
@@ -839,13 +861,50 @@
</div> </div>
<div class="modal-footer border-0 pt-0"> <div class="modal-footer border-0 pt-0">
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button> <button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-primary" onclick="document.getElementById('sendInvoiceForm').submit()"> <button type="button" class="btn btn-primary" onclick="submitSendInvoice(true, false)">
<i class="bi bi-send me-1"></i>Yes, Send Invoice <i class="bi bi-send me-1"></i>Yes, Send Invoice
</button> </button>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
@if (showSendModal)
{
<!-- Send Channel Choice Modal (shown when customer has both email + SMS) -->
<div class="modal fade" id="sendChannelModal" tabindex="-1" aria-labelledby="sendChannelModalLabel" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header border-0 pb-0">
<h5 class="modal-title" id="sendChannelModalLabel">
<i class="bi bi-send text-primary me-2"></i>Send Invoice
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body pt-2">
<p class="mb-3">How would you like to send <strong>@Model.InvoiceNumber</strong> to <strong>@Model.CustomerName</strong>?</p>
<div class="d-grid gap-2">
<button type="button" class="btn btn-outline-primary text-start" onclick="submitSendInvoice(true, false)" data-bs-dismiss="modal">
<i class="bi bi-envelope me-2"></i>Email only
<small class="d-block text-muted ms-4">PDF attached · @Model.CustomerEmail</small>
</button>
<button type="button" class="btn btn-outline-primary text-start" onclick="submitSendInvoice(false, true)" data-bs-dismiss="modal">
<i class="bi bi-phone me-2"></i>SMS only
<small class="d-block text-muted ms-4">View link · @smsPhone</small>
</button>
<button type="button" class="btn btn-primary text-start" onclick="submitSendInvoice(true, true)" data-bs-dismiss="modal">
<i class="bi bi-send me-2"></i>Both Email &amp; SMS
<small class="d-block text-muted ms-4">PDF via email + view link via SMS</small>
</button>
</div>
</div>
<div class="modal-footer border-0 pt-0">
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
</div>
</div>
</div>
</div>
}
} }
@if (canPay) @if (canPay)
@@ -1145,6 +1204,20 @@
<option value="5">Store Credit</option> <option value="5">Store Credit</option>
</select> </select>
</div> </div>
<div class="mb-3" id="refundDepositAccountRow">
<label class="form-label fw-semibold">Refund From Account</label>
<select name="DepositAccountId" class="form-select">
<option value="">(Not tracked)</option>
@if (ViewBag.BankAccounts != null)
{
@foreach (var acct in (IEnumerable<SelectListItem>)ViewBag.BankAccounts)
{
<option value="@acct.Value">@acct.Text</option>
}
}
</select>
<div class="form-text">Bank or cash account the refund is paid from.</div>
</div>
<div class="mb-3"> <div class="mb-3">
<label class="form-label fw-semibold">Reason <span class="text-danger">*</span></label> <label class="form-label fw-semibold">Reason <span class="text-danger">*</span></label>
<input type="text" name="Reason" class="form-control" placeholder="e.g. Warranty claim, duplicate charge..." required /> <input type="text" name="Reason" class="form-control" placeholder="e.g. Warranty claim, duplicate charge..." required />
@@ -1171,6 +1244,7 @@
document.getElementById('refundAlertCash').classList.toggle('d-none', isCredit); document.getElementById('refundAlertCash').classList.toggle('d-none', isCredit);
document.getElementById('refundAlertCredit').classList.toggle('d-none', !isCredit); document.getElementById('refundAlertCredit').classList.toggle('d-none', !isCredit);
document.getElementById('refundReferenceRow').classList.toggle('d-none', isCredit); document.getElementById('refundReferenceRow').classList.toggle('d-none', isCredit);
document.getElementById('refundDepositAccountRow').classList.toggle('d-none', isCredit);
}); });
</script> </script>
</div> </div>
@@ -1366,6 +1440,12 @@
@section Scripts { @section Scripts {
<script> <script>
function submitSendInvoice(sendEmail, sendSms) {
document.getElementById('sendInvoiceSendEmail').value = sendEmail ? 'true' : 'false';
document.getElementById('sendInvoiceSendSms').value = sendSms ? 'true' : 'false';
document.getElementById('sendInvoiceForm').submit();
}
function openEditPaymentModal(paymentId, invoiceId, paymentDate, paymentMethod, reference, notes, depositAccountId) { function openEditPaymentModal(paymentId, invoiceId, paymentDate, paymentMethod, reference, notes, depositAccountId) {
document.getElementById('editPaymentId').value = paymentId; document.getElementById('editPaymentId').value = paymentId;
document.getElementById('editPaymentDate').value = paymentDate; document.getElementById('editPaymentDate').value = paymentDate;
@@ -0,0 +1,98 @@
@{
ViewData["Title"] = "Kiosk Setup";
bool isActivated = ViewBag.IsActivated as bool? ?? false;
}
<div class="container-fluid px-4">
<div class="d-flex align-items-center gap-3 mb-4">
<i class="bi bi-tablet fs-3 text-primary"></i>
<div>
<h1 class="h3 fw-bold mb-0">Kiosk Setup</h1>
<p class="text-muted mb-0">Configure the front-desk intake tablet</p>
</div>
</div>
@if (TempData["Success"] != null)
{
<div class="alert alert-success alert-permanent mb-4">
<i class="bi bi-check-circle me-2"></i> @TempData["Success"]
</div>
}
@if (TempData["Error"] != null)
{
<div class="alert alert-danger alert-permanent mb-4">
<i class="bi bi-exclamation-triangle me-2"></i> @TempData["Error"]
</div>
}
<div class="row g-4">
@* Status card *@
<div class="col-md-6">
<div class="card h-100">
<div class="card-body">
<h5 class="card-title fw-semibold mb-3">Current Status</h5>
@if (isActivated)
{
<div class="d-flex align-items-center gap-2 mb-3">
<span class="badge bg-success fs-6 px-3 py-2">
<i class="bi bi-check-circle me-1"></i> Active
</span>
</div>
<p class="text-muted">
A kiosk device is currently activated. The tablet will respond to
"Start Intake" commands from your staff.
</p>
<form method="post" asp-action="Activate">
@Html.AntiForgeryToken()
<input type="hidden" name="action" value="deactivate" />
<button type="submit" class="btn btn-outline-danger"
onclick="return confirm('Deactivate the kiosk? The tablet will no longer receive intake requests.');">
<i class="bi bi-tablet me-1"></i> Deactivate Kiosk
</button>
</form>
}
else
{
<div class="d-flex align-items-center gap-2 mb-3">
<span class="badge bg-secondary fs-6 px-3 py-2">
<i class="bi bi-dash-circle me-1"></i> Not Activated
</span>
</div>
<p class="text-muted">
No kiosk device is activated. Click below to activate this browser
session as the kiosk device.
</p>
<form method="post" asp-action="Activate">
@Html.AntiForgeryToken()
<input type="hidden" name="action" value="activate" />
<button type="submit" class="btn btn-primary">
<i class="bi bi-tablet me-1"></i> Activate This Device
</button>
</form>
}
</div>
</div>
</div>
@* Instructions card *@
<div class="col-md-6">
<div class="card h-100">
<div class="card-body">
<h5 class="card-title fw-semibold mb-3">Setup Instructions</h5>
<ol class="text-muted" style="line-height:2;">
<li>Open this page on the <strong>tablet</strong> and tap <em>Activate This Device</em>.</li>
<li>After activation, navigate to <code>/Kiosk/Welcome</code> on the tablet.</li>
<li>Bookmark that page so it survives a browser restart.</li>
<li>Keep the tablet browser open — SignalR maintains a live connection.</li>
<li>Use <em>Start Customer Intake</em> on the Dashboard or Jobs list to push a session to the tablet.</li>
</ol>
<div class="alert alert-info mb-0">
<i class="bi bi-info-circle me-2"></i>
Only one device can be active at a time. Re-activating replaces the previous device token.
</div>
</div>
</div>
</div>
</div>
</div>
@@ -0,0 +1,53 @@
@{
Layout = "~/Views/Shared/_KioskLayout.cshtml";
ViewData["Title"] = "Thank You";
bool isInPerson = ViewBag.IsInPerson as bool? ?? false;
string firstName = ViewBag.FirstName as string ?? "there";
}
<div class="kiosk-confirmation py-5">
<div class="kiosk-confirmation-icon">
<i class="bi bi-check-circle-fill"></i>
</div>
<h2 class="fw-bold" style="font-size:2rem;">Thank you, @firstName!</h2>
@if (isInPerson)
{
<p class="text-muted mt-2" style="font-size:1.1rem;">
A team member will be right with you.
</p>
<p class="kiosk-countdown" id="countdown-msg">
Returning to the welcome screen in <span id="countdown">30</span> seconds…
</p>
}
else
{
<p class="text-muted mt-2" style="font-size:1.1rem;">
We've received your intake form and will be in touch soon.
</p>
<p class="text-muted mt-4" style="font-size:0.95rem;">
You can close this window.
</p>
}
</div>
@if (isInPerson)
{
@section Scripts {
<script>
(function () {
var secs = 30;
var el = document.getElementById("countdown");
var interval = setInterval(function () {
secs--;
if (el) el.textContent = secs;
if (secs <= 0) {
clearInterval(interval);
window.location.href = "@ViewBag.WelcomeUrl";
}
}, 1000);
})();
</script>
}
}
@@ -0,0 +1,59 @@
@model PowderCoating.Application.DTOs.Kiosk.SubmitKioskContactDto
@{
Layout = "~/Views/Shared/_KioskLayout.cshtml";
ViewData["Title"] = "Your Information";
var token = ViewBag.SessionToken as Guid? ?? Guid.Empty;
}
<div class="kiosk-card">
<h2 class="fw-bold mb-1" style="font-size:1.6rem;">Tell us about yourself</h2>
<p class="text-muted mb-4">All fields are required.</p>
<form method="post" action="/Kiosk/Intake/@token/Contact" id="contactForm">
@Html.AntiForgeryToken()
<div class="row g-3">
<div class="col-sm-6">
<label asp-for="FirstName" class="form-label">First Name</label>
<input asp-for="FirstName" class="form-control" autocomplete="given-name"
autocapitalize="words" spellcheck="false" placeholder="Jane" />
<span asp-validation-for="FirstName" class="text-danger small"></span>
</div>
<div class="col-sm-6">
<label asp-for="LastName" class="form-label">Last Name</label>
<input asp-for="LastName" class="form-control" autocomplete="family-name"
autocapitalize="words" spellcheck="false" placeholder="Smith" />
<span asp-validation-for="LastName" class="text-danger small"></span>
</div>
</div>
<div class="mt-3">
<label asp-for="Phone" class="form-label">Phone Number</label>
<input asp-for="Phone" class="form-control" type="tel" inputmode="tel"
autocomplete="tel" placeholder="(555) 555-0100" />
<span asp-validation-for="Phone" class="text-danger small"></span>
</div>
<div class="mt-3">
<label asp-for="Email" class="form-label">Email Address</label>
<input asp-for="Email" class="form-control" type="email" inputmode="email"
autocomplete="email" placeholder="jane@example.com" />
<span asp-validation-for="Email" class="text-danger small"></span>
</div>
<div class="mt-4 p-3 rounded-3" style="background:#f1f5f9;">
<div class="form-check">
<input asp-for="IsReturningCustomer" class="form-check-input" type="checkbox" />
<label asp-for="IsReturningCustomer" class="form-check-label">
I've been a customer before
</label>
</div>
</div>
<div class="mt-4">
<button type="submit" class="btn btn-primary kiosk-btn">
Continue <i class="bi bi-arrow-right ms-2"></i>
</button>
</div>
</form>
</div>
@@ -0,0 +1,46 @@
@model PowderCoating.Application.DTOs.Kiosk.SubmitKioskJobDto
@{
Layout = "~/Views/Shared/_KioskLayout.cshtml";
ViewData["Title"] = "About Your Project";
var token = ViewBag.SessionToken as Guid? ?? Guid.Empty;
}
<div class="kiosk-card">
<h2 class="fw-bold mb-1" style="font-size:1.6rem;">What brings you in?</h2>
<p class="text-muted mb-4">Tell us a little about what you need coated.</p>
<form method="post" action="/Kiosk/Intake/@token/Job">
@Html.AntiForgeryToken()
<div class="mb-3">
<label asp-for="JobDescription" class="form-label">Describe your project</label>
<textarea asp-for="JobDescription" class="form-control" rows="5"
placeholder="e.g. Motorcycle frame, two-tone black and chrome, remove old coating first..."
style="min-height:160px;resize:none;"></textarea>
<span asp-validation-for="JobDescription" class="text-danger small"></span>
</div>
<div class="mb-4">
<label asp-for="HowDidYouHearAboutUs" class="form-label">How did you hear about us? <span class="text-muted fw-normal">(optional)</span></label>
<select asp-for="HowDidYouHearAboutUs" class="form-select">
<option value="">— Select one —</option>
<option>Google / Online Search</option>
<option>Friend or Family Referral</option>
<option>Social Media</option>
<option>Drove by the shop</option>
<option>Returning Customer</option>
<option>Other</option>
</select>
</div>
<div class="d-flex gap-3">
<a href="/Kiosk/Intake/@token/Contact" class="btn btn-outline-secondary"
style="min-height:64px;border-radius:12px;font-size:1.1rem;flex:0 0 auto;padding:0 2rem;">
<i class="bi bi-arrow-left me-1"></i> Back
</a>
<button type="submit" class="btn btn-primary kiosk-btn">
Continue <i class="bi bi-arrow-right ms-2"></i>
</button>
</div>
</form>
</div>
@@ -0,0 +1,107 @@
@model PowderCoating.Application.DTOs.Kiosk.SubmitKioskTermsDto
@{
Layout = "~/Views/Shared/_KioskLayout.cshtml";
ViewData["Title"] = "Terms & Consent";
var token = ViewBag.SessionToken as Guid? ?? Guid.Empty;
bool isInPerson = ViewBag.IsInPerson as bool? ?? false;
bool quoteFirst = !string.Equals(ViewBag.KioskIntakeOutput as string, "Job", StringComparison.OrdinalIgnoreCase);
}
<div class="kiosk-card">
<h2 class="fw-bold mb-1" style="font-size:1.6rem;">Terms & Consent</h2>
<p class="text-muted mb-4">Please read and agree to the following before we proceed.</p>
<form method="post" action="/Kiosk/Intake/@token/Terms" id="termsForm">
@Html.AntiForgeryToken()
@* Terms scroll box *@
<div class="kiosk-terms-scroll mb-4">
<strong>Work Authorization &amp; Liability Waiver</strong>
<p class="mt-2">
By signing below (or checking the box), you authorize @(ViewBag.CompanyName ?? "this shop")
to perform the powder coating services described in your intake form.
</p>
<p>
You acknowledge that you are the owner of the items submitted for coating, or you
have authority to authorize work on them. You release the shop from liability for
pre-existing damage, hidden defects, or items left unclaimed after 30 days.
</p>
@if (quoteFirst)
{
<p>
Final pricing is subject to a formal quote. Work will not begin until you approve
the quoted amount. Payment is due upon pickup unless otherwise agreed in writing.
</p>
}
else
{
<p>
A team member will review your intake and reach out about pricing before work begins.
Payment is due upon pickup unless otherwise agreed in writing.
</p>
}
<p class="mb-0">
You agree to comply with all pickup and payment terms provided by the shop.
</p>
</div>
@* SMS consent — separate checkbox per plan *@
<div class="p-3 rounded-3 mb-3" style="background:#f0f9ff;border:1px solid #bae6fd;">
<div class="form-check">
<input asp-for="SmsOptIn" class="form-check-input" type="checkbox" />
<label asp-for="SmsOptIn" class="form-check-label">
I consent to receive SMS text messages with updates about my order.
<span class="text-muted d-block mt-1" style="font-size:0.85rem;">
Message and data rates may apply. Reply STOP to opt out at any time.
</span>
</label>
</div>
</div>
@* Terms agreement *@
<div class="p-3 rounded-3 mb-4" style="background:#f8fafc;border:1px solid #e2e8f0;">
<div class="form-check">
<input asp-for="AgreedToTerms" class="form-check-input" type="checkbox" required />
<label asp-for="AgreedToTerms" class="form-check-label fw-semibold">
I have read and agree to the terms above.
</label>
<span asp-validation-for="AgreedToTerms" class="text-danger d-block small mt-1"></span>
</div>
</div>
@* Signature pad — in-person only *@
@if (isInPerson)
{
<div class="mb-4">
<label class="form-label fw-semibold">Your Signature</label>
<canvas id="signatureCanvas"></canvas>
<div id="signatureError" class="text-danger small mt-1 d-none">
Please sign above before continuing.
</div>
<input type="hidden" id="SignatureDataBase64" name="SignatureDataBase64" />
<button type="button" id="clearSignatureBtn"
class="btn btn-sm btn-outline-secondary mt-2">
<i class="bi bi-eraser me-1"></i> Clear
</button>
</div>
}
<div class="d-flex gap-3">
<a href="/Kiosk/Intake/@token/Job" class="btn btn-outline-secondary"
style="min-height:64px;border-radius:12px;font-size:1.1rem;flex:0 0 auto;padding:0 2rem;">
<i class="bi bi-arrow-left me-1"></i> Back
</a>
<button type="submit" class="btn btn-success kiosk-btn">
<i class="bi bi-check-circle me-2"></i> Submit
</button>
</div>
</form>
</div>
@if (isInPerson)
{
@section Scripts {
<script src="~/lib/signature-pad/signature_pad.umd.min.js"></script>
<script src="~/js/kiosk-terms.js"></script>
}
}
@@ -0,0 +1,169 @@
@model List<PowderCoating.Application.DTOs.Kiosk.KioskSessionListDto>
@using PowderCoating.Core.Enums
@{
ViewData["Title"] = "Customer Intakes";
string activeFilter = ViewBag.ActiveFilter as string ?? "all";
}
<div class="container-fluid px-4">
<div class="d-flex align-items-center justify-content-between mb-4 flex-wrap gap-2">
<div class="d-flex align-items-center gap-3">
<i class="bi bi-clipboard-check fs-3 text-primary"></i>
<div>
<h1 class="h3 fw-bold mb-0">Customer Intakes</h1>
<p class="text-muted mb-0">Walk-in and remote intake sessions</p>
</div>
</div>
<div class="d-flex gap-2">
<a href="/Kiosk/SendRemoteLink" class="btn btn-outline-primary btn-sm">
<i class="bi bi-envelope-at me-1"></i> Send Remote Link
</a>
</div>
</div>
@* Filter tabs *@
<ul class="nav nav-tabs mb-4">
<li class="nav-item">
<a class="nav-link @(activeFilter == "all" ? "active" : "")" href="?filter=all">All (@Model.Count)</a>
</li>
<li class="nav-item">
<a class="nav-link @(activeFilter == "submitted" ? "active" : "")" href="?filter=submitted">
Submitted (@Model.Count(d => d.Status == KioskSessionStatus.Submitted))
</a>
</li>
<li class="nav-item">
<a class="nav-link @(activeFilter == "active" ? "active" : "")" href="?filter=active">
Pending (@Model.Count(d => d.Status == KioskSessionStatus.Active && !d.IsExpired))
</a>
</li>
<li class="nav-item">
<a class="nav-link @(activeFilter == "expired" ? "active" : "")" href="?filter=expired">
Expired (@Model.Count(d => d.IsExpired))
</a>
</li>
</ul>
@if (!Model.Any())
{
<div class="text-center py-5 text-muted">
<i class="bi bi-inbox fs-1 mb-3 d-block"></i>
<p>No intake sessions found.</p>
</div>
}
else
{
<div class="card">
<div class="table-responsive">
<table class="table table-hover mb-0 align-middle">
<thead class="table-light">
<tr>
<th>Date</th>
<th>Customer</th>
<th>Contact</th>
<th>Project</th>
<th>Type</th>
<th>Status</th>
<th>SMS</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
@foreach (var s in Model)
{
<tr>
<td class="text-nowrap text-muted small">
@(s.SubmittedAt?.ToLocalTime().ToString("MM/dd/yy h:mm tt") ?? s.ExpiresAt.AddHours(-2).ToLocalTime().ToString("MM/dd/yy h:mm tt"))
</td>
<td>
<div class="fw-semibold">@s.CustomerFullName</div>
@if (s.LinkedCustomerId.HasValue)
{
<a href="/Customers/Details/@s.LinkedCustomerId" class="small text-success">
<i class="bi bi-person-check me-1"></i>Customer matched
</a>
}
</td>
<td class="small text-muted">
@if (!string.IsNullOrEmpty(s.CustomerPhone))
{
<div><i class="bi bi-telephone me-1"></i>@s.CustomerPhone</div>
}
@if (!string.IsNullOrEmpty(s.CustomerEmail))
{
<div><i class="bi bi-envelope me-1"></i>@s.CustomerEmail</div>
}
</td>
<td style="max-width:280px;">
<span class="text-truncate d-block" style="max-width:260px;"
title="@s.JobDescription">@s.JobDescriptionSnippet</span>
</td>
<td>
@if (s.SessionType == KioskSessionType.InPerson)
{
<span class="badge bg-primary-subtle text-primary">
<i class="bi bi-tablet me-1"></i>In-Person
</span>
}
else
{
<span class="badge bg-purple-subtle text-purple" style="background:#ede9fe;color:#6d28d9;">
<i class="bi bi-envelope me-1"></i>Remote
</span>
}
</td>
<td>
@if (s.Status == KioskSessionStatus.Submitted && s.IsConverted)
{
<span class="badge bg-success">Converted</span>
}
else if (s.Status == KioskSessionStatus.Submitted)
{
<span class="badge bg-info text-dark">Submitted</span>
}
else if (s.Status == KioskSessionStatus.Active && !s.IsExpired)
{
<span class="badge bg-warning text-dark">In Progress</span>
}
else
{
<span class="badge bg-secondary">Expired</span>
}
</td>
<td>
@if (s.SmsOptIn)
{
<i class="bi bi-check-circle-fill text-success" title="SMS opt-in"></i>
}
else
{
<i class="bi bi-dash text-muted"></i>
}
</td>
<td class="text-nowrap">
@if (s.LinkedJobId.HasValue)
{
<a href="/Jobs/Details/@s.LinkedJobId" class="btn btn-sm btn-outline-success me-1">
<i class="bi bi-briefcase me-1"></i>View Job
</a>
}
@if (s.LinkedQuoteId.HasValue)
{
<a href="/Quotes/Details/@s.LinkedQuoteId" class="btn btn-sm btn-outline-info me-1">
<i class="bi bi-file-earmark-text me-1"></i>View Quote
</a>
}
@if (s.LinkedCustomerId.HasValue)
{
<a href="/Customers/Details/@s.LinkedCustomerId" class="btn btn-sm btn-outline-primary">
<i class="bi bi-person me-1"></i>Customer
</a>
}
</td>
</tr>
}
</tbody>
</table>
</div>
</div>
}
</div>
@@ -0,0 +1,13 @@
@model string
@{
Layout = "~/Views/Shared/_KioskLayout.cshtml";
ViewData["Title"] = "Unable to Start";
ViewBag.ShowInactivityTimer = false;
}
<div class="kiosk-card text-center py-5">
<i class="bi bi-exclamation-triangle-fill text-warning" style="font-size:4rem;"></i>
<h2 class="mt-3 fw-bold">Something went wrong</h2>
<p class="text-muted mt-2">@Model</p>
<p class="mt-4 text-muted" style="font-size:0.9rem;">Please ask a staff member for assistance.</p>
</div>
@@ -0,0 +1,68 @@
@model PowderCoating.Application.DTOs.Kiosk.SendRemoteLinkDto
@{
ViewData["Title"] = "Send Intake Link";
}
<div class="container-fluid px-4">
<div class="d-flex align-items-center gap-3 mb-4">
<i class="bi bi-envelope-at fs-3 text-primary"></i>
<div>
<h1 class="h3 fw-bold mb-0">Send Remote Intake Link</h1>
<p class="text-muted mb-0">Email a customer an intake form they can fill out on their own device</p>
</div>
</div>
@if (TempData["Success"] != null)
{
<div class="alert alert-success alert-permanent mb-4">
<i class="bi bi-check-circle me-2"></i> @TempData["Success"]
</div>
}
<div class="row">
<div class="col-md-6">
<div class="card">
<div class="card-body">
<form method="post" asp-action="SendRemoteLink">
@Html.AntiForgeryToken()
<div asp-validation-summary="ModelOnly" class="alert alert-danger alert-permanent mb-3"></div>
<div class="mb-3">
<label asp-for="Email" class="form-label fw-semibold">Customer Email Address</label>
<input asp-for="Email" class="form-control" type="email"
placeholder="customer@example.com" autofocus />
<span asp-validation-for="Email" class="text-danger small"></span>
</div>
<div class="mb-4">
<label asp-for="CustomerName" class="form-label fw-semibold">
Customer Name <span class="text-muted fw-normal">(optional)</span>
</label>
<input asp-for="CustomerName" class="form-control"
placeholder="Used to personalise the email greeting" />
</div>
<button type="submit" class="btn btn-primary">
<i class="bi bi-send me-2"></i> Send Intake Link
</button>
<a href="/Dashboard" class="btn btn-link ms-2">Cancel</a>
</form>
</div>
</div>
</div>
<div class="col-md-6">
<div class="card bg-light border-0">
<div class="card-body">
<h6 class="fw-semibold mb-2"><i class="bi bi-info-circle me-2 text-primary"></i>How it works</h6>
<ul class="text-muted small mb-0" style="line-height:1.8;">
<li>The customer receives an email with a unique, secure link.</li>
<li>They fill out their contact info and describe their project on their own phone or computer.</li>
<li>When they submit, a Pending job is automatically created and you're notified.</li>
<li>The link expires in 48 hours.</li>
</ul>
</div>
</div>
</div>
</div>
</div>
@@ -0,0 +1,53 @@
@model int
@{
Layout = "~/Views/Shared/_KioskLayout.cshtml";
ViewData["Title"] = "SMS Consent";
string customerName = ViewBag.CustomerName as string ?? "Customer";
}
<div class="kiosk-card">
<h2 class="fw-bold mb-1" style="font-size:1.6rem;">SMS Notifications</h2>
<p class="text-muted mb-4">Please read the following and tap <strong>I Agree</strong> to opt in.</p>
<form method="post" action="/Kiosk/SmsConsent/@Model">
@Html.AntiForgeryToken()
<input type="hidden" name="agreed" value="true" />
<div class="kiosk-terms-scroll mb-4">
<strong>SMS Consent &amp; Opt-In</strong>
<p class="mt-2">
By tapping <em>I Agree</em> below, <strong>@customerName</strong> consents to receive
SMS text messages from @(ViewBag.CompanyName ?? "this shop") regarding order status
updates, pickup notifications, and other information related to your powder coating
services.
</p>
<p>
Message frequency varies. Message and data rates may apply.
You may opt out at any time by replying <strong>STOP</strong> to any message.
Reply <strong>HELP</strong> for assistance.
</p>
<p class="mb-0">
Your mobile number will not be shared with third parties or used for marketing
unrelated to your orders.
</p>
</div>
<div class="d-flex gap-3">
<a href="/Kiosk/SmsConsent/@Model?agreed=false"
onclick="event.preventDefault(); document.getElementById('declineForm').submit();"
class="btn btn-outline-secondary"
style="min-height:64px;border-radius:12px;font-size:1.1rem;flex:0 0 auto;padding:0 2rem;">
<i class="bi bi-x-lg me-1"></i> No Thanks
</a>
<button type="submit" class="btn btn-success kiosk-btn">
<i class="bi bi-check-circle me-2"></i> I Agree
</button>
</div>
</form>
@* Separate form for decline so "No Thanks" can POST with agreed=false *@
<form id="declineForm" method="post" action="/Kiosk/SmsConsent/@Model" style="display:none;">
@Html.AntiForgeryToken()
<input type="hidden" name="agreed" value="false" />
</form>
</div>
@@ -0,0 +1,33 @@
@{
Layout = "~/Views/Shared/_KioskLayout.cshtml";
ViewData["Title"] = "Welcome";
ViewBag.HideLayoutLogo = true;
}
<div id="kiosk-welcome-root"
data-company-id="@ViewBag.CompanyId"
class="kiosk-welcome-screen">
@if (!string.IsNullOrEmpty(ViewBag.CompanyLogoUrl as string))
{
<img src="@ViewBag.CompanyLogoUrl"
alt="@ViewBag.CompanyName"
class="kiosk-welcome-logo" />
}
else
{
<h1 class="kiosk-welcome-title">@ViewBag.CompanyName</h1>
}
<p class="kiosk-welcome-subtitle">Welcome! A staff member will start your intake shortly.</p>
<div class="kiosk-idle-indicator">
<span id="kiosk-conn-dot" style="display:inline-block;width:10px;height:10px;
border-radius:50%;background:#94a3b8;margin-right:6px;transition:background 0.3s;"></span>
<span id="kiosk-conn-label">Connecting…</span>
</div>
</div>
@section Scripts {
<script src="~/js/kiosk-welcome.js"></script>
}
@@ -1,6 +1,6 @@
@model PowderCoating.Application.DTOs.Common.PagedResult<PowderCoating.Application.DTOs.Notification.NotificationLogDto> @model PowderCoating.Application.DTOs.Common.PagedResult<PowderCoating.Application.DTOs.Notification.NotificationLogDto>
@{ @{
ViewData["Title"] = "Notification Log"; ViewData["Title"] = "Email & SMS Log";
ViewData["PageIcon"] = "bi-bell-history"; ViewData["PageIcon"] = "bi-bell-history";
var sortCol = ViewBag.SortColumn as string ?? "SentAt"; var sortCol = ViewBag.SortColumn as string ?? "SentAt";
var sortDir = ViewBag.SortDirection as string ?? "desc"; var sortDir = ViewBag.SortDirection as string ?? "desc";
@@ -0,0 +1,182 @@
@model InvoiceViewViewModel
@using PowderCoating.Core.Enums
@{
Layout = "~/Views/Shared/_QuoteApprovalLayout.cshtml";
ViewData["Title"] = $"Invoice {Model.InvoiceNumber}";
var isPaid = Model.BalanceDue <= 0;
}
<div class="container py-4" style="max-width:780px;">
@* ── Header ── *@
<div class="text-center mb-4">
@if (!string.IsNullOrEmpty(Model.LogoFilePath))
{
<img src="/media/@(Model.LogoFilePath.TrimStart('/'))" alt="@Model.CompanyName" style="max-height:80px;max-width:240px;object-fit:contain;" class="mb-3" />
}
else
{
<img src="/images/pcl-logo.png" alt="@Model.CompanyName" style="max-height:60px;" class="mb-3" />
}
<h4 class="fw-semibold mb-0">@Model.CompanyName</h4>
@if (!string.IsNullOrEmpty(Model.CompanyPhone))
{
<p class="text-muted small mb-0">@Model.CompanyPhone</p>
}
@if (!string.IsNullOrEmpty(Model.CompanyAddress))
{
<p class="text-muted small">@Model.CompanyAddress</p>
}
</div>
@* ── Invoice meta ── *@
<div class="card border-0 shadow-sm mb-4">
<div class="card-body">
<div class="row g-3">
<div class="col-6">
<p class="text-muted small mb-1">Invoice</p>
<p class="fw-semibold mb-0">@Model.InvoiceNumber</p>
</div>
<div class="col-6 text-end">
<p class="text-muted small mb-1">Date</p>
<p class="fw-semibold mb-0">@Model.InvoiceDate.ToString("MMM d, yyyy")</p>
</div>
<div class="col-6">
<p class="text-muted small mb-1">Bill To</p>
<p class="fw-semibold mb-0">@Model.CustomerName</p>
</div>
@if (Model.DueDate.HasValue)
{
<div class="col-6 text-end">
<p class="text-muted small mb-1">Due Date</p>
<p class="fw-semibold mb-0 @(Model.DueDate < DateTime.UtcNow && !isPaid ? "text-danger" : "")">
@Model.DueDate.Value.ToString("MMM d, yyyy")
</p>
</div>
}
@if (!string.IsNullOrEmpty(Model.JobNumber))
{
<div class="col-6">
<p class="text-muted small mb-1">Job</p>
<p class="fw-semibold mb-0">@Model.JobNumber</p>
</div>
}
</div>
</div>
</div>
@* ── Line items ── *@
<div class="card border-0 shadow-sm mb-4">
<div class="card-body p-0">
<table class="table table-sm mb-0">
<thead class="table-light">
<tr>
<th class="ps-3">Description</th>
<th class="text-center" style="width:70px;">Qty</th>
<th class="text-end" style="width:100px;">Unit Price</th>
<th class="text-end pe-3" style="width:110px;">Total</th>
</tr>
</thead>
<tbody>
@foreach (var item in Model.LineItems)
{
<tr>
<td class="ps-3">@item.Description</td>
<td class="text-center">@item.Quantity.ToString("G29")</td>
<td class="text-end">@item.UnitPrice.ToString("C")</td>
<td class="text-end pe-3">@item.TotalPrice.ToString("C")</td>
</tr>
}
</tbody>
</table>
</div>
</div>
@* ── Totals ── *@
<div class="card border-0 shadow-sm mb-4">
<div class="card-body">
<div class="d-flex justify-content-between mb-1">
<span class="text-muted">Subtotal</span>
<span>@Model.SubTotal.ToString("C")</span>
</div>
@if (Model.DiscountAmount > 0)
{
<div class="d-flex justify-content-between mb-1 text-success">
<span>Discount</span>
<span>-@Model.DiscountAmount.ToString("C")</span>
</div>
}
@if (Model.TaxAmount > 0)
{
<div class="d-flex justify-content-between mb-1">
<span class="text-muted">Tax (@Model.TaxPercent.ToString("0.##")%)</span>
<span>@Model.TaxAmount.ToString("C")</span>
</div>
}
<hr class="my-2" />
<div class="d-flex justify-content-between fw-semibold">
<span>Total</span>
<span>@Model.Total.ToString("C")</span>
</div>
@if (Model.AmountPaid > 0)
{
<div class="d-flex justify-content-between text-success mt-1">
<span>Amount Paid</span>
<span>-@Model.AmountPaid.ToString("C")</span>
</div>
<hr class="my-2" />
<div class="d-flex justify-content-between fw-bold fs-5 @(isPaid ? "text-success" : "text-danger")">
<span>Balance Due</span>
<span>@Model.BalanceDue.ToString("C")</span>
</div>
}
</div>
</div>
@* ── Pay button ── *@
@if (!isPaid && !string.IsNullOrEmpty(Model.PaymentUrl))
{
<div class="text-center mb-4">
<a href="@Model.PaymentUrl" class="btn btn-success btn-lg px-5">
<i class="bi bi-credit-card me-2"></i>Pay @Model.BalanceDue.ToString("C") Online
</a>
<p class="text-muted small mt-2">Secure payment powered by Stripe. This pay link expires in 5 days.</p>
</div>
}
else if (isPaid)
{
<div class="alert alert-success text-center" role="alert">
<i class="bi bi-check-circle-fill me-2"></i>This invoice has been paid in full. Thank you!
</div>
}
else
{
<div class="alert alert-info text-center" role="alert">
<i class="bi bi-info-circle me-2"></i>To arrange payment, please contact @Model.CompanyName@(!string.IsNullOrEmpty(Model.CompanyPhone) ? $" at {Model.CompanyPhone}" : "").
</div>
}
@* ── Notes / Terms ── *@
@if (!string.IsNullOrEmpty(Model.Notes))
{
<div class="card border-0 shadow-sm mb-3">
<div class="card-body">
<p class="text-muted small mb-1 fw-semibold">Notes</p>
<p class="mb-0 small">@Model.Notes</p>
</div>
</div>
}
@if (!string.IsNullOrEmpty(Model.Terms))
{
<div class="card border-0 shadow-sm mb-3">
<div class="card-body">
<p class="text-muted small mb-1 fw-semibold">Payment Terms</p>
<p class="mb-0 small">@Model.Terms</p>
</div>
</div>
}
<p class="text-center text-muted small mt-4">
Questions? Contact @Model.CompanyName@(!string.IsNullOrEmpty(Model.CompanyPhone) ? $" at {Model.CompanyPhone}" : "").
</p>
</div>
@@ -5,32 +5,101 @@
ViewData["Title"] = "Platform Settings"; ViewData["Title"] = "Platform Settings";
ViewData["PageIcon"] = "bi-gear-wide-connected"; ViewData["PageIcon"] = "bi-gear-wide-connected";
var groups = Model.GroupBy(s => s.GroupName ?? "General").OrderBy(g => g.Key); var groups = Model.GroupBy(s => s.GroupName ?? "General").OrderBy(g => g.Key);
static string GroupIcon(string group) => group switch
{
"Notifications" => "bi-bell",
"Subscriptions" => "bi-credit-card",
"Quotes" => "bi-file-earmark-text",
"Data Retention" => "bi-clock-history",
"AI" => "bi-robot",
_ => "bi-globe"
};
static string GroupDescription(string group) => group switch
{
"Notifications" => "Controls where platform event alerts (signups, bug reports, billing events) are sent, and whether SMS is active.",
"Subscriptions" => "Trial and billing defaults applied to new tenant companies at registration, including grace periods and tenant limits.",
"Quotes" => "Default validity windows and token expiry for customer-facing quote links.",
"Data Retention" => "How long audit and webhook records are kept before the nightly purge removes them.",
"AI" => "Platform-level feature flags for AI-powered features. These override any company-level settings.",
_ => "Core platform configuration values used across features and email links."
};
static bool IsBool(string key) =>
key.EndsWith("Enabled", StringComparison.OrdinalIgnoreCase) ||
key.EndsWith("AppliesToTrials", StringComparison.OrdinalIgnoreCase) ||
key.EndsWith("TrialsEnabled", StringComparison.OrdinalIgnoreCase);
static string InputType(string key) => key switch
{
var k when k.Contains("Email", StringComparison.OrdinalIgnoreCase) => "email",
var k when k.Contains("Url", StringComparison.OrdinalIgnoreCase) => "url",
var k when k.Contains("Days", StringComparison.OrdinalIgnoreCase) => "number",
var k when k.Contains("Max", StringComparison.OrdinalIgnoreCase) => "number",
_ => "text"
};
static string InputHint(string key) => key switch
{
var k when k.Contains("Email", StringComparison.OrdinalIgnoreCase) => "e.g. admin@example.com",
var k when k.Contains("Url", StringComparison.OrdinalIgnoreCase) => "e.g. https://app.yourdomain.com",
var k when k.Contains("Days", StringComparison.OrdinalIgnoreCase) => "Enter a whole number of days",
var k when k.Contains("Max", StringComparison.OrdinalIgnoreCase) => "Enter a whole number, or -1 for unlimited",
_ => ""
};
} }
@section Styles { @section Styles {
<style> <style>
[data-bs-theme="dark"] a.text-dark { color: var(--bs-body-color) !important; } [data-bs-theme="dark"] .card-header.bg-white,
[data-bs-theme="dark"] .settings-group-header { background-color: var(--bs-card-cap-bg) !important; }
.settings-group-header { background: var(--bs-body-bg); border-bottom: 1px solid var(--bs-border-color); }
.setting-meta { font-size: 0.75rem; color: var(--bs-secondary-color); margin-top: 2px; }
</style> </style>
} }
<div class="mb-4"></div> <div class="container-fluid py-2">
@if (TempData["SuccessMessage"] != null)
{
<div class="alert alert-success alert-permanent alert-dismissible fade show mb-4" role="alert">
<i class="bi bi-check-circle-fill me-2"></i>@TempData["SuccessMessage"]
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
}
@foreach (var group in groups)
{
var groupKey = group.Key;
var icon = GroupIcon(groupKey);
var desc = GroupDescription(groupKey);
@foreach (var group in groups)
{
<div class="card border-0 shadow-sm mb-4"> <div class="card border-0 shadow-sm mb-4">
<div class="card-header bg-white border-0 py-3"> <div class="card-header settings-group-header py-3">
<h5 class="mb-0 fw-semibold"> <div class="d-flex align-items-start gap-3">
<i class="bi bi-sliders me-2 text-primary"></i>@group.Key <div class="rounded-3 p-2 bg-primary bg-opacity-10 flex-shrink-0">
</h5> <i class="bi @icon text-primary fs-5"></i>
</div>
<div>
<h5 class="mb-0 fw-semibold">@groupKey</h5>
<p class="mb-0 small text-muted mt-1">@desc</p>
</div>
</div>
</div> </div>
<div class="card-body p-0"> <div class="card-body p-0">
<div class="table-responsive"> <div class="table-responsive d-none d-lg-block">
<table class="table mb-0"> <table class="table mb-0">
<thead> <colgroup>
<col style="width:32%">
<col style="width:43%">
<col style="width:25%">
</colgroup>
<thead class="table-light">
<tr> <tr>
<th class="ps-4" style="width:30%">Setting</th> <th class="ps-4 fw-semibold">Setting</th>
<th style="width:45%">Value</th> <th class="fw-semibold">Current Value</th>
<th class="text-end pe-4" style="width:25%">Actions</th> <th class="text-end pe-4 fw-semibold">Actions</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -43,40 +112,77 @@
{ {
<small class="text-muted">@s.Description</small> <small class="text-muted">@s.Description</small>
} }
<div class="setting-meta font-monospace mt-1 text-muted">@s.Key</div>
</td> </td>
<td class="align-middle"> <td class="align-middle">
@if (string.IsNullOrWhiteSpace(s.Value)) @if (IsBool(s.Key))
{
var isOn = string.Equals(s.Value, "true", StringComparison.OrdinalIgnoreCase);
<form asp-action="Save" method="post" class="d-inline">
@Html.AntiForgeryToken()
<input type="hidden" name="key" value="@s.Key" />
<div class="btn-group btn-group-sm" role="group">
<input type="radio" class="btn-check" name="value"
id="@(s.Key)_yes" value="true"
@(isOn ? "checked" : "")
onchange="this.form.submit()" />
<label class="btn @(isOn ? "btn-success" : "btn-outline-success")" for="@(s.Key)_yes">Yes</label>
<input type="radio" class="btn-check" name="value"
id="@(s.Key)_no" value="false"
@(!isOn ? "checked" : "")
onchange="this.form.submit()" />
<label class="btn @(!isOn ? "btn-danger" : "btn-outline-danger")" for="@(s.Key)_no">No</label>
</div>
</form>
}
else if (string.IsNullOrWhiteSpace(s.Value))
{ {
<span class="text-muted fst-italic">Not set</span> <span class="text-muted fst-italic">Not set</span>
} }
else else
{ {
<span>@s.Value</span> <span class="fw-medium">@s.Value</span>
}
@if (s.UpdatedAt.HasValue)
{
<div class="setting-meta">
Updated @s.UpdatedAt.Value.ToString("MMM d, yyyy")
@if (!string.IsNullOrWhiteSpace(s.UpdatedBy))
{
<span>by @s.UpdatedBy</span>
}
</div>
} }
</td> </td>
<td class="text-end pe-4 align-middle"> <td class="text-end pe-4 align-middle">
@if (!IsBool(s.Key))
{
<button class="btn btn-sm btn-outline-primary" <button class="btn btn-sm btn-outline-primary"
data-bs-toggle="modal" data-bs-toggle="modal"
data-bs-target="#editModal" data-bs-target="#editModal"
data-key="@s.Key" data-key="@s.Key"
data-label="@(s.Label ?? s.Key)" data-label="@(s.Label ?? s.Key)"
data-value="@s.Value"> data-value="@(s.Value ?? "")"
data-type="@InputType(s.Key)"
data-hint="@InputHint(s.Key)">
<i class="bi bi-pencil me-1"></i>Edit <i class="bi bi-pencil me-1"></i>Edit
</button> </button>
}
</td> </td>
</tr> </tr>
} }
</tbody> </tbody>
</table> </table>
</div> </div>
<!-- Mobile card view — shown on screens < 992px -->
<div class="mobile-card-view"> @* Mobile card view *@
<div class="mobile-card-view d-lg-none">
<div class="mobile-card-list"> <div class="mobile-card-list">
@foreach (var s in group) @foreach (var s in group)
{ {
<div class="mobile-data-card"> <div class="mobile-data-card">
<div class="mobile-card-header"> <div class="mobile-card-header">
<div class="mobile-card-icon bg-primary"><i class="bi bi-sliders"></i></div> <div class="mobile-card-icon bg-primary"><i class="bi @icon"></i></div>
<div class="mobile-card-title"> <div class="mobile-card-title">
<h6>@(s.Label ?? s.Key)</h6> <h6>@(s.Label ?? s.Key)</h6>
@if (!string.IsNullOrWhiteSpace(s.Description)) @if (!string.IsNullOrWhiteSpace(s.Description))
@@ -99,6 +205,16 @@
} }
</span> </span>
</div> </div>
@if (s.UpdatedAt.HasValue)
{
<div class="mobile-card-row">
<span class="mobile-card-label">Updated</span>
<span class="mobile-card-value text-muted small">
@s.UpdatedAt.Value.ToString("MMM d, yyyy")
@if (!string.IsNullOrWhiteSpace(s.UpdatedBy)) { <text>by @s.UpdatedBy</text> }
</span>
</div>
}
<div class="mobile-card-row"> <div class="mobile-card-row">
<span class="mobile-card-label">Key</span> <span class="mobile-card-label">Key</span>
<span class="mobile-card-value text-muted small font-monospace">@s.Key</span> <span class="mobile-card-value text-muted small font-monospace">@s.Key</span>
@@ -110,7 +226,9 @@
data-bs-target="#editModal" data-bs-target="#editModal"
data-key="@s.Key" data-key="@s.Key"
data-label="@(s.Label ?? s.Key)" data-label="@(s.Label ?? s.Key)"
data-value="@s.Value"> data-value="@(s.Value ?? "")"
data-type="@InputType(s.Key)"
data-hint="@InputHint(s.Key)">
<i class="bi bi-pencil me-1"></i>Edit <i class="bi bi-pencil me-1"></i>Edit
</button> </button>
</div> </div>
@@ -120,32 +238,40 @@
</div> </div>
</div> </div>
</div> </div>
} }
@if (!Model.Any()) @if (!Model.Any())
{ {
<div class="card border-0 shadow-sm"> <div class="card border-0 shadow-sm">
<div class="card-body text-center py-5"> <div class="card-body text-center py-5">
<i class="bi bi-sliders fs-1 text-muted opacity-50"></i> <i class="bi bi-gear-wide-connected fs-1 text-muted opacity-50"></i>
<p class="mt-3 text-muted">No platform settings found. Run a database migration to seed defaults.</p> <p class="mt-3 text-muted">No platform settings found. Run a database migration to seed defaults.</p>
</div> </div>
</div> </div>
} }
</div>
<!-- Edit Modal --> <!-- Edit Modal -->
<div class="modal fade" id="editModal" tabindex="-1"> <div class="modal fade" id="editModal" tabindex="-1" aria-labelledby="editModalLabel" aria-hidden="true">
<div class="modal-dialog"> <div class="modal-dialog">
<div class="modal-content"> <div class="modal-content">
<form asp-action="Save" method="post"> <form asp-action="Save" method="post">
@Html.AntiForgeryToken() @Html.AntiForgeryToken()
<input type="hidden" id="editKey" name="key" /> <input type="hidden" id="editKey" name="key" />
<div class="modal-header"> <div class="modal-header">
<h5 class="modal-title">Edit Setting: <span id="editLabel"></span></h5> <h5 class="modal-title" id="editModalLabel">
<i class="bi bi-pencil-square me-2 text-primary"></i>Edit: <span id="editLabel"></span>
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button> <button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div> </div>
<div class="modal-body"> <div class="modal-body">
<label class="form-label fw-semibold" for="editValue">Value</label> <label class="form-label fw-semibold" for="editValue">Value</label>
<input type="text" class="form-control" id="editValue" name="value" /> <input class="form-control" id="editValue" name="value" />
<div class="form-text" id="editHint"></div>
<div class="form-text text-muted mt-2">
Leave blank to clear the setting and use the built-in default.
</div>
</div> </div>
<div class="modal-footer"> <div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button> <button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
@@ -162,9 +288,24 @@
<script> <script>
document.getElementById('editModal').addEventListener('show.bs.modal', function (e) { document.getElementById('editModal').addEventListener('show.bs.modal', function (e) {
const btn = e.relatedTarget; const btn = e.relatedTarget;
const type = btn.dataset.type || 'text';
const hint = btn.dataset.hint || '';
const input = document.getElementById('editValue');
document.getElementById('editKey').value = btn.dataset.key; document.getElementById('editKey').value = btn.dataset.key;
document.getElementById('editLabel').textContent = btn.dataset.label; document.getElementById('editLabel').textContent = btn.dataset.label;
document.getElementById('editValue').value = btn.dataset.value ?? ''; document.getElementById('editHint').textContent = hint;
input.type = type;
input.value = btn.dataset.value ?? '';
if (type === 'number') {
input.min = '0';
input.step = '1';
} else {
input.removeAttribute('min');
input.removeAttribute('step');
}
}); });
</script> </script>
} }
@@ -18,7 +18,21 @@
} }
<div class="container-fluid"> <div class="container-fluid">
<div class="mb-4"></div> <div class="mb-2">
<a asp-controller="PlatformAdmin" asp-action="Maintenance" class="text-muted small text-decoration-none">
<i class="bi bi-arrow-left me-1"></i>Maintenance
</a>
</div>
<environment include="Production">
<div class="alert alert-danger alert-permanent d-flex gap-3 align-items-start mb-4">
<i class="bi bi-exclamation-octagon-fill fs-4 flex-shrink-0 mt-1 text-danger"></i>
<div>
<div class="fw-semibold">You are running in Production</div>
<div class="small mt-1">Seed operations write data directly to the live database. Only proceed if you have a specific, intentional reason — for example, onboarding a new company. Do not seed the default demo company in production.</div>
</div>
</div>
</environment>
@if (TempData["SuccessMessage"] != null) @if (TempData["SuccessMessage"] != null)
{ {
@@ -0,0 +1,93 @@
<!DOCTYPE html>
<html lang="en" data-bs-theme="light">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no" />
<title>@(ViewData["Title"] ?? "Customer Intake") — @(ViewBag.CompanyName ?? "Intake Form")</title>
<link href="~/lib/bootstrap/css/bootstrap.min.css" rel="stylesheet" />
<link rel="stylesheet" href="~/lib/bootstrap-icons/font/bootstrap-icons.css" />
<link rel="stylesheet" href="~/css/kiosk.css" />
@await RenderSectionAsync("Styles", required: false)
</head>
<body class="kiosk-body">
@{
int kioskStep = ViewBag.KioskStep ?? 0; // 1, 2, or 3 — 0 means no step dots
int kioskSteps = ViewBag.KioskSteps ?? 3;
}
<div class="container py-4" style="max-width:720px;">
@* Logo — hidden on Welcome screen which renders its own centered logo *@
@if (!(bool)(ViewBag.HideLayoutLogo ?? false))
{
<div class="text-center mb-3">
@if (!string.IsNullOrEmpty(ViewBag.CompanyLogoUrl as string))
{
<img src="@ViewBag.CompanyLogoUrl" alt="@ViewBag.CompanyName"
style="max-height:80px;max-width:220px;object-fit:contain;" />
}
else
{
<span class="fw-bold fs-5 text-muted">@ViewBag.CompanyName</span>
}
</div>
}
@* Step dots *@
@if (kioskStep > 0)
{
<div class="kiosk-steps mb-4" aria-label="Step @kioskStep of @kioskSteps">
@for (int i = 1; i <= kioskSteps; i++)
{
string dotClass = i < kioskStep ? "done" : (i == kioskStep ? "active" : "");
<div class="kiosk-step-dot @dotClass" title="Step @i"></div>
}
</div>
}
@* Validation summary *@
@if (ViewData.ModelState.IsValid == false)
{
<div class="alert alert-danger alert-permanent mb-4">
<i class="bi bi-exclamation-triangle me-2"></i>
Please correct the highlighted fields below.
</div>
}
@RenderBody()
</div>
@* Inactivity timer — redirect to Welcome when idle too long.
Intake steps set ViewBag.InactivityTimeoutMs = 45000 (45 s).
Welcome screen keeps the default 5-minute timeout. *@
@{
bool showInactivityTimer = (bool)(ViewBag.ShowInactivityTimer ?? true);
string welcomeUrl = ViewBag.WelcomeUrl as string ?? "/Kiosk/Welcome";
int inactivityMs = ViewBag.InactivityTimeoutMs as int? ?? (5 * 60 * 1000);
}
@if (showInactivityTimer)
{
<script>
(function () {
var TIMEOUT_MS = @inactivityMs;
var timer;
function reset() {
clearTimeout(timer);
timer = setTimeout(function () {
window.location.href = "@Html.Raw(welcomeUrl)";
}, TIMEOUT_MS);
}
["touchstart", "touchmove", "click", "keydown", "scroll"].forEach(function (evt) {
document.addEventListener(evt, reset, { passive: true });
});
reset();
})();
</script>
}
<script src="~/lib/bootstrap/js/bootstrap.bundle.min.js"></script>
@await RenderSectionAsync("Scripts", required: false)
</body>
</html>
@@ -1136,6 +1136,13 @@
<span>Daily Board</span> <span>Daily Board</span>
</a> </a>
} }
@if (hasJobs)
{
<a asp-controller="Kiosk" asp-action="Intakes" class="nav-link" data-nav="ops">
<i class="bi bi-tablet"></i>
<span>Intake Sessions</span>
</a>
}
@* ── Billing & Payments ───────────────────────────────────── *@ @* ── Billing & Payments ───────────────────────────────────── *@
@if (hasInvoices) @if (hasInvoices)
@@ -1294,6 +1301,15 @@
<i class="bi bi-people"></i> <i class="bi bi-people"></i>
<span>People &amp; Activity</span> <span>People &amp; Activity</span>
</a> </a>
<a asp-controller="UserActivity" asp-action="Online" class="nav-link">
<i class="bi bi-broadcast-pin"></i>
<span>Online Now</span>
@{ var _onlineCount = OnlineUserTracker.GetActiveCount(); }
@if (_onlineCount > 0)
{
<span class="badge bg-success ms-auto">@_onlineCount</span>
}
</a>
<a asp-controller="PlatformAdmin" asp-action="ContentMessaging" class="nav-link"> <a asp-controller="PlatformAdmin" asp-action="ContentMessaging" class="nav-link">
<i class="bi bi-megaphone"></i> <i class="bi bi-megaphone"></i>
<span>Content &amp; Messaging</span> <span>Content &amp; Messaging</span>
@@ -1483,6 +1499,7 @@
<li><a class="dropdown-item" asp-controller="CompanyUsers" asp-action="Index"><i class="bi bi-people-fill me-2"></i>Manage Users</a></li> <li><a class="dropdown-item" asp-controller="CompanyUsers" asp-action="Index"><i class="bi bi-people-fill me-2"></i>Manage Users</a></li>
<li><a class="dropdown-item" asp-controller="PricingTiers" asp-action="Index"><i class="bi bi-tags me-2"></i>Pricing Tiers</a></li> <li><a class="dropdown-item" asp-controller="PricingTiers" asp-action="Index"><i class="bi bi-tags me-2"></i>Pricing Tiers</a></li>
<li><a class="dropdown-item" asp-controller="TaxRates" asp-action="Index"><i class="bi bi-percent me-2"></i>Tax Rates</a></li> <li><a class="dropdown-item" asp-controller="TaxRates" asp-action="Index"><i class="bi bi-percent me-2"></i>Tax Rates</a></li>
<li><a class="dropdown-item" asp-controller="Kiosk" asp-action="Activate"><i class="bi bi-tablet me-2"></i>Kiosk Setup</a></li>
} }
<li><hr class="dropdown-divider"></li> <li><hr class="dropdown-divider"></li>
@if (gearIsAdmin) @if (gearIsAdmin)
@@ -1491,7 +1508,7 @@
<li><a class="dropdown-item" asp-controller="Tools" asp-action="Index"><i class="bi bi-wrench-adjustable me-2"></i>Tools</a></li> <li><a class="dropdown-item" asp-controller="Tools" asp-action="Index"><i class="bi bi-wrench-adjustable me-2"></i>Tools</a></li>
<li><hr class="dropdown-divider"></li> <li><hr class="dropdown-divider"></li>
} }
<li><a class="dropdown-item" asp-controller="NotificationLogs" asp-action="Index"><i class="bi bi-bell me-2"></i>Notification Log</a></li> <li><a class="dropdown-item" asp-controller="NotificationLogs" asp-action="Index"><i class="bi bi-bell me-2"></i>Email &amp; SMS Log</a></li>
<li><a class="dropdown-item" asp-controller="BugReport" asp-action="Submit"><i class="bi bi-bug me-2"></i>Report a Bug</a></li> <li><a class="dropdown-item" asp-controller="BugReport" asp-action="Submit"><i class="bi bi-bug me-2"></i>Report a Bug</a></li>
</ul> </ul>
</div> </div>
@@ -1897,7 +1914,8 @@
const icons = { const icons = {
QuoteApproved: { icon: 'bi-check-circle-fill', cls: 'success', title: 'Quote Approved' }, QuoteApproved: { icon: 'bi-check-circle-fill', cls: 'success', title: 'Quote Approved' },
QuoteDeclined: { icon: 'bi-x-circle-fill', cls: 'danger', title: 'Quote Declined' }, QuoteDeclined: { icon: 'bi-x-circle-fill', cls: 'danger', title: 'Quote Declined' },
InvoicePaid: { icon: 'bi-cash-coin', cls: 'primary', title: 'Payment Received' } InvoicePaid: { icon: 'bi-cash-coin', cls: 'primary', title: 'Payment Received' },
KioskConsent: { icon: 'bi-chat-fill', cls: 'success', title: 'SMS Consent' }
}; };
const t = icons[data.notificationType] || { icon: 'bi-bell', cls: 'info', title: 'Notification' }; const t = icons[data.notificationType] || { icon: 'bi-bell', cls: 'info', title: 'Notification' };
toastr[t.cls === 'danger' ? 'warning' : t.cls === 'primary' ? 'info' : 'success']( toastr[t.cls === 'danger' ? 'warning' : t.cls === 'primary' ? 'info' : 'success'](
@@ -1905,6 +1923,12 @@
`<i class="bi ${t.icon} me-1"></i>${t.title}`, `<i class="bi ${t.icon} me-1"></i>${t.title}`,
{ timeOut: 10000, extendedTimeOut: 3000, closeButton: true, enableHtml: true } { timeOut: 10000, extendedTimeOut: 3000, closeButton: true, enableHtml: true }
); );
if (data.notificationType === 'KioskConsent' && data.customerId) {
const path = window.location.pathname.toLowerCase();
if (path === `/customers/details/${data.customerId}`) {
window.updateCustomerSmsStatus?.();
}
}
}); });
connection.start().catch(err => console.warn('SignalR connection failed:', err)); connection.start().catch(err => console.warn('SignalR connection failed:', err));
@@ -2084,8 +2108,14 @@
}); });
}); });
// Load on page ready // Load on page ready and refresh when dropdown is opened
document.addEventListener('DOMContentLoaded', load); document.addEventListener('DOMContentLoaded', () => {
load();
btn?.addEventListener('show.bs.dropdown', load);
});
// Fallback poll every 60 s in case SignalR misses a push
setInterval(load, 60_000);
return { addItem, incrementBadge, markAllRead, openDetail, markRead }; return { addItem, incrementBadge, markAllRead, openDetail, markRead };
})(); })();
@@ -6,6 +6,21 @@
} }
<div class="container-fluid"> <div class="container-fluid">
<div class="mb-2">
<a asp-controller="PlatformAdmin" asp-action="Maintenance" class="text-muted small text-decoration-none">
<i class="bi bi-arrow-left me-1"></i>Maintenance
</a>
</div>
<environment include="Production">
<div class="alert alert-warning alert-permanent d-flex gap-3 align-items-start mb-4">
<i class="bi bi-exclamation-triangle-fill fs-4 flex-shrink-0 mt-1"></i>
<div>
<div class="fw-semibold">This tool is not needed in Production</div>
<div class="small mt-1">The platform has already migrated to Azure Blob Storage. Running this migration again in production is unnecessary and may cause unintended side effects. If you believe there is a specific reason to proceed, verify with the team first.</div>
</div>
</div>
</environment>
<div class="row g-4"> <div class="row g-4">
<!-- Status Card --> <!-- Status Card -->
@@ -14,6 +14,11 @@
</style> </style>
} }
<div class="mb-2">
<a asp-controller="PlatformAdmin" asp-action="Observability" class="text-muted small text-decoration-none">
<i class="bi bi-arrow-left me-1"></i>Observability
</a>
</div>
<div class="d-flex justify-content-end align-items-center mb-4"> <div class="d-flex justify-content-end align-items-center mb-4">
<span class="badge bg-secondary">Read-only diagnostics</span> <span class="badge bg-secondary">Read-only diagnostics</span>
</div> </div>
@@ -41,6 +41,11 @@
} }
<div class="container-fluid py-3"> <div class="container-fluid py-3">
<div class="mb-2">
<a asp-controller="PlatformAdmin" asp-action="Observability" class="text-muted small text-decoration-none">
<i class="bi bi-arrow-left me-1"></i>Observability
</a>
</div>
<div class="d-flex align-items-center justify-content-between mb-3"> <div class="d-flex align-items-center justify-content-between mb-3">
<div> <div>
<h4 class="mb-0"><i class="bi bi-database-exclamation me-2 text-danger"></i>System Logs</h4> <h4 class="mb-0"><i class="bi bi-database-exclamation me-2 text-danger"></i>System Logs</h4>
@@ -33,6 +33,11 @@
} }
<div class="container-fluid py-3"> <div class="container-fluid py-3">
<div class="mb-2">
<a asp-controller="PlatformAdmin" asp-action="Observability" class="text-muted small text-decoration-none">
<i class="bi bi-arrow-left me-1"></i>Observability
</a>
</div>
<div class="d-flex align-items-center justify-content-between mb-3"> <div class="d-flex align-items-center justify-content-between mb-3">
<h4 class="mb-0"> <h4 class="mb-0">
<i class="bi bi-speedometer2 me-2 text-primary"></i>Usage &amp; Quota <i class="bi bi-speedometer2 me-2 text-primary"></i>Usage &amp; Quota
+206
View File
@@ -0,0 +1,206 @@
/* ── Kiosk touch-optimised styles ─────────────────────────────────────────── */
:root {
--kiosk-accent: #2563eb;
--kiosk-radius: 12px;
--kiosk-input-h: 56px;
--kiosk-btn-h: 64px;
}
body.kiosk-body {
font-size: 1.125rem;
background: #f8fafc;
min-height: 100dvh;
display: flex;
flex-direction: column;
}
/* ── Inputs ── */
.kiosk-input,
.kiosk-body .form-control,
.kiosk-body .form-select {
min-height: var(--kiosk-input-h);
border-radius: var(--kiosk-radius);
font-size: 1.125rem;
padding: 0.75rem 1rem;
border: 2px solid #cbd5e1;
transition: border-color 0.15s;
}
.kiosk-body .form-control:focus,
.kiosk-body .form-select:focus {
border-color: var(--kiosk-accent);
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.15);
}
.kiosk-body textarea.form-control {
min-height: 140px;
resize: none;
}
.kiosk-body .form-check-input {
width: 1.5rem;
height: 1.5rem;
margin-top: 0.15rem;
}
.kiosk-body .form-check-label {
font-size: 1rem;
padding-left: 0.5rem;
}
/* ── Buttons ── */
.kiosk-btn,
.kiosk-body .btn-primary,
.kiosk-body .btn-success {
min-height: var(--kiosk-btn-h);
border-radius: var(--kiosk-radius);
font-size: 1.2rem;
font-weight: 600;
width: 100%;
}
/* Vertically centre content in any tall kiosk button (covers <a> and <button>) */
.kiosk-body .btn,
.kiosk-btn {
display: inline-flex;
align-items: center;
justify-content: center;
}
/* Suppress all hover effects on touch screens */
@media (hover: none) {
.kiosk-body .btn:hover { filter: none; opacity: 1; }
}
/* ── Step progress dots ── */
.kiosk-steps {
display: flex;
justify-content: center;
align-items: center;
gap: 10px;
margin: 1.25rem 0;
}
.kiosk-step-dot {
width: 14px;
height: 14px;
border-radius: 50%;
background: #cbd5e1;
transition: background 0.2s;
}
.kiosk-step-dot.active {
background: var(--kiosk-accent);
transform: scale(1.2);
}
.kiosk-step-dot.done {
background: #16a34a;
}
/* ── Card ── */
.kiosk-card {
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 1rem;
box-shadow: 0 2px 8px rgba(0,0,0,0.06);
padding: 2rem;
width: 100%;
max-width: 680px;
margin: 0 auto;
}
/* ── Signature canvas ── */
#signatureCanvas {
border: 2px solid #cbd5e1;
border-radius: var(--kiosk-radius);
background: #fff;
width: 100%;
height: 200px;
cursor: crosshair;
touch-action: none;
}
#signatureCanvas.signed {
border-color: #16a34a;
}
/* ── Welcome screen ── */
.kiosk-welcome-screen {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 100dvh;
text-align: center;
padding: 2rem;
}
.kiosk-welcome-logo {
max-height: 200px;
max-width: 420px;
object-fit: contain;
margin-bottom: 2rem;
}
.kiosk-welcome-title {
font-size: 2.5rem;
font-weight: 700;
color: #1e293b;
margin-bottom: 0.75rem;
}
.kiosk-welcome-subtitle {
font-size: 1.25rem;
color: #64748b;
}
.kiosk-idle-indicator {
margin-top: 3rem;
font-size: 0.9rem;
color: #94a3b8;
}
/* ── Confirmation screen ── */
.kiosk-confirmation {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 60vh;
text-align: center;
}
.kiosk-confirmation-icon {
font-size: 5rem;
color: #16a34a;
margin-bottom: 1.5rem;
}
.kiosk-countdown {
font-size: 0.9rem;
color: #94a3b8;
margin-top: 2rem;
}
/* ── Terms scroll box ── */
.kiosk-terms-scroll {
max-height: 260px;
overflow-y: auto;
border: 1px solid #e2e8f0;
border-radius: var(--kiosk-radius);
padding: 1.25rem;
background: #f8fafc;
font-size: 0.9rem;
color: #475569;
line-height: 1.6;
margin-bottom: 1.5rem;
}
/* ── Labels ── */
.kiosk-body .form-label {
font-weight: 600;
margin-bottom: 0.5rem;
color: #1e293b;
}
@@ -0,0 +1,174 @@
(function () {
'use strict';
// ── Tab persistence via URL hash ──────────────────────────────────────────
var hash = window.location.hash;
if (hash) {
var btn = document.querySelector('#companyDetailTabs button[data-bs-target="' + hash.replace('#', '#pane-') + '"]');
if (!btn) btn = document.querySelector('#companyDetailTabs button[data-bs-target="' + hash + '"]');
if (btn) bootstrap.Tab.getOrCreateInstance(btn).show();
}
document.querySelectorAll('#companyDetailTabs button[data-bs-toggle="tab"]').forEach(function (btn) {
btn.addEventListener('shown.bs.tab', function (e) {
var target = e.target.getAttribute('data-bs-target').replace('#pane-', '#');
history.replaceState(null, '', target);
});
});
// ── Reset Data modal ──────────────────────────────────────────────────────
(function () {
var input = document.getElementById('resetDataConfirmInput');
var hidden = document.getElementById('resetDataConfirmHidden');
var btn = document.getElementById('btnResetDataConfirm');
if (!input) return;
input.addEventListener('input', function () {
var ok = input.value.trim() === 'DELETE';
btn.disabled = !ok;
hidden.value = input.value.trim();
});
document.getElementById('resetDataModal').addEventListener('hidden.bs.modal', function () {
input.value = ''; hidden.value = ''; btn.disabled = true;
});
})();
// ── Hard Delete modal ─────────────────────────────────────────────────────
(function () {
var input = document.getElementById('hardDeleteConfirmInput');
var hidden = document.getElementById('hardDeleteConfirmHidden');
var btn = document.getElementById('btnHardDeleteConfirm');
if (!input) return;
input.addEventListener('input', function () {
var ok = input.value.trim() === 'DELETE';
btn.disabled = !ok;
hidden.value = input.value.trim();
});
document.getElementById('hardDeleteModal').addEventListener('hidden.bs.modal', function () {
input.value = ''; hidden.value = ''; btn.disabled = true;
});
})();
// ── Reset Password modal ──────────────────────────────────────────────────
var resetPasswordModal = document.getElementById('resetPasswordModal');
if (resetPasswordModal) {
resetPasswordModal.addEventListener('show.bs.modal', function (e) {
var btn = e.relatedTarget;
document.getElementById('resetUserId').value = btn.getAttribute('data-user-id');
document.getElementById('resetUserName').textContent = btn.getAttribute('data-user-name');
});
}
// ── User Details modal (login history) ───────────────────────────────────
var offcanvasEl = document.getElementById('userDetailOffcanvas');
if (!offcanvasEl) return;
var oc = new bootstrap.Modal(offcanvasEl);
var loading = document.getElementById('oc-loading');
var content = document.getElementById('oc-content');
var errorDiv = document.getElementById('oc-error');
var errorMsg = document.getElementById('oc-error-msg');
var actionBadgeClass = {
'Login': 'bg-success',
'Login2FABypassed': 'bg-success',
'FailedLogin': 'bg-danger',
'LoginDenied': 'bg-warning text-dark',
'AccountLockedOut': 'bg-danger',
};
var actionLabel = {
'Login': 'Login',
'Login2FABypassed': 'Login (2FA bypassed)',
'FailedLogin': 'Failed login',
'LoginDenied': 'Login denied',
'AccountLockedOut': 'Account locked out',
'SelfServiceAccountDeletion': 'Account deleted',
};
function showLoading() {
loading.classList.remove('d-none');
content.style.display = 'none';
errorDiv.style.display = 'none';
}
function showError(msg) {
loading.classList.add('d-none');
content.style.display = 'none';
errorMsg.textContent = msg;
errorDiv.style.display = '';
}
function escHtml(str) {
return String(str)
.replace(/&/g, '&amp;').replace(/</g, '&lt;')
.replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
function renderUser(data) {
var u = data.user;
document.getElementById('oc-name').textContent = u.fullName;
document.getElementById('oc-fullname').textContent = u.fullName;
document.getElementById('oc-email').textContent = u.email;
var badge = document.getElementById('oc-status-badge');
badge.textContent = u.isActive ? 'Active' : 'Inactive';
badge.className = 'badge ms-auto ' + (u.isActive ? 'bg-success' : 'bg-danger');
document.getElementById('oc-role').textContent = u.companyRole ? u.companyRole.replace('Company', '') : '—';
document.getElementById('oc-dept').textContent = u.department || '—';
document.getElementById('oc-position').textContent = u.position || '—';
document.getElementById('oc-phone').textContent = u.phone || '—';
document.getElementById('oc-hire').textContent = u.hireDate || '—';
document.getElementById('oc-created').textContent = u.createdAt || '—';
document.getElementById('oc-lastlogin').textContent = u.lastLoginDate || 'Never';
document.getElementById('oc-emailconf').innerHTML = u.emailConfirmed
? '<span class="text-success"><i class="bi bi-check-circle-fill me-1"></i>Yes</span>'
: '<span class="text-warning"><i class="bi bi-x-circle-fill me-1"></i>No</span>';
var tbody = document.getElementById('oc-log-body');
var noLogs = document.getElementById('oc-no-logs');
var logWrap = document.getElementById('oc-log-table-wrap');
var countEl = document.getElementById('oc-log-count');
var logs = data.loginHistory;
tbody.innerHTML = '';
if (!logs || logs.length === 0) {
noLogs.style.display = '';
logWrap.style.display = 'none';
countEl.textContent = '';
} else {
noLogs.style.display = 'none';
logWrap.style.display = '';
countEl.textContent = '(' + logs.length + ')';
logs.forEach(function (log) {
var bc = actionBadgeClass[log.action] || 'bg-secondary';
var label = actionLabel[log.action] || log.action;
var note = log.note ? '<br><span class="text-muted">' + escHtml(log.note) + '</span>' : '';
var tr = document.createElement('tr');
tr.innerHTML =
'<td class="text-nowrap">' + escHtml(log.timestamp) + '</td>' +
'<td><span class="badge ' + bc + '">' + escHtml(label) + '</span>' + note + '</td>' +
'<td class="text-nowrap text-muted">' + escHtml(log.ipAddress) + '</td>';
tbody.appendChild(tr);
});
}
loading.classList.add('d-none');
content.style.display = '';
}
document.querySelectorAll('.user-row').forEach(function (row) {
row.addEventListener('click', function () {
showLoading();
oc.show();
fetch('/Companies/UserLoginHistory?companyId=' +
encodeURIComponent(row.dataset.companyId) +
'&userId=' + encodeURIComponent(row.dataset.userId))
.then(function (r) {
if (!r.ok) throw new Error('Server returned ' + r.status);
return r.json();
})
.then(renderUser)
.catch(function (err) { showError('Failed to load user data. ' + err.message); });
});
});
})();
@@ -0,0 +1,49 @@
"use strict";
async function pushSmsConsent(customerId) {
const tok = document.querySelector('input[name="__RequestVerificationToken"]')?.value ?? '';
try {
const res = await fetch(`/Kiosk/PushSmsConsent?customerId=${customerId}`, {
method: 'POST',
headers: { 'RequestVerificationToken': tok }
});
const data = await res.json();
if (data.success) {
toastr.success('Consent form sent to the kiosk tablet — hand it to the customer.', 'Sent to Kiosk');
document.getElementById('btnGetSmsConsent')?.classList.add('d-none');
document.getElementById('btnCancelSmsConsent')?.classList.remove('d-none');
} else {
toastr.warning(data.message || 'Could not send consent to kiosk.');
}
} catch {
toastr.error('An error occurred. Please try again.');
}
}
async function cancelSmsConsent() {
const tok = document.querySelector('input[name="__RequestVerificationToken"]')?.value ?? '';
try {
const res = await fetch('/Kiosk/CancelSmsConsent', {
method: 'POST',
headers: { 'RequestVerificationToken': tok }
});
const data = await res.json();
if (data.success) {
toastr.info('Consent request cancelled — kiosk is free.');
document.getElementById('btnCancelSmsConsent')?.classList.add('d-none');
document.getElementById('btnGetSmsConsent')?.classList.remove('d-none');
}
} catch {
toastr.error('An error occurred. Please try again.');
}
}
window.updateCustomerSmsStatus = function () {
const section = document.getElementById('sms-status-section');
if (!section) return;
const today = new Date().toLocaleDateString('en-US', { month: '2-digit', day: '2-digit', year: 'numeric' });
section.innerHTML = `<span class="badge bg-success bg-opacity-10 text-success border border-success border-opacity-25"
title="Consented ${today}">
<i class="bi bi-chat-fill me-1"></i>SMS on
</span>`;
};
@@ -1166,16 +1166,50 @@ function aiHandleDrop(event) {
Array.from(event.dataTransfer.files).forEach(aiUploadFile); Array.from(event.dataTransfer.files).forEach(aiUploadFile);
} }
// Resize + recompress an image file before upload so phone photos (5-15 MB)
// don't saturate mobile upload bandwidth or slow down Anthropic processing.
// Max 1200px on the long edge, JPEG at 85% quality — ~150-250 KB typical output.
// Non-image files and GIFs are returned unchanged.
async function aiCompressImage(file, maxPx = 1200, quality = 0.85) {
if (!file.type.startsWith('image/') || file.type === 'image/gif') return file;
return new Promise(resolve => {
const reader = new FileReader();
reader.onload = e => {
const img = new Image();
img.onload = () => {
const scale = Math.min(1, maxPx / Math.max(img.width, img.height));
const w = Math.round(img.width * scale);
const h = Math.round(img.height * scale);
const canvas = document.createElement('canvas');
canvas.width = w;
canvas.height = h;
canvas.getContext('2d').drawImage(img, 0, 0, w, h);
canvas.toBlob(blob => {
if (!blob || blob.size >= file.size) { resolve(file); return; }
resolve(new File([blob], file.name.replace(/\.[^.]+$/, '.jpg'), { type: 'image/jpeg' }));
}, 'image/jpeg', quality);
};
img.onerror = () => resolve(file);
img.src = e.target.result;
};
reader.onerror = () => resolve(file);
reader.readAsDataURL(file);
});
}
async function aiUploadFile(file) { async function aiUploadFile(file) {
// Read as data: URL — blob: URLs are blocked by CSP; data: is explicitly allowed // Compress before uploading — full-res phone photos slow upload + Anthropic API
const compressed = await aiCompressImage(file);
// Read compressed bytes for the thumbnail preview (blob: URLs blocked by CSP)
const previewUrl = await new Promise(resolve => { const previewUrl = await new Promise(resolve => {
const reader = new FileReader(); const reader = new FileReader();
reader.onload = e => resolve(e.target.result); reader.onload = e => resolve(e.target.result);
reader.onerror = () => resolve(''); reader.onerror = () => resolve('');
reader.readAsDataURL(file); reader.readAsDataURL(compressed);
}); });
const formData = new FormData(); const formData = new FormData();
formData.append('file', file); formData.append('file', compressed);
formData.append('__RequestVerificationToken', formData.append('__RequestVerificationToken',
document.querySelector('input[name="__RequestVerificationToken"]')?.value || ''); document.querySelector('input[name="__RequestVerificationToken"]')?.value || '');
@@ -1278,15 +1312,27 @@ async function aiAnalyze() {
}; };
const analyzeUrl = pageMeta.aiAnalyzeUrl || '/Quotes/AiAnalyzeItem'; const analyzeUrl = pageMeta.aiAnalyzeUrl || '/Quotes/AiAnalyzeItem';
const controller = new AbortController();
// Abort after 120 s — server-side Anthropic timeout is 60 s per attempt with retries;
// 120 s gives room for one retry plus network round-trip on a slow mobile connection.
const hardTimeout = setTimeout(() => controller.abort(), 120_000);
// After 30 s without a response, update the spinner text so the user knows it's working.
const slowWarning = setTimeout(() => {
const t = document.getElementById('ai_loadingText');
if (t) t.textContent = 'Still analyzing… this can take a minute on mobile connections.';
}, 30_000);
try { try {
const resp = await fetch(analyzeUrl, { const resp = await fetch(analyzeUrl, {
method: 'POST', method: 'POST',
signal: controller.signal,
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
'RequestVerificationToken': document.querySelector('input[name="__RequestVerificationToken"]')?.value || '' 'RequestVerificationToken': document.querySelector('input[name="__RequestVerificationToken"]')?.value || ''
}, },
body: JSON.stringify(payload) body: JSON.stringify(payload)
}); });
clearTimeout(hardTimeout);
clearTimeout(slowWarning);
if (!resp.ok) { if (!resp.ok) {
if (resp.status === 401 || resp.status === 302 || resp.redirected) { if (resp.status === 401 || resp.status === 302 || resp.redirected) {
throw new Error('Your session has expired. Please refresh the page and sign in again.'); throw new Error('Your session has expired. Please refresh the page and sign in again.');
@@ -1301,10 +1347,16 @@ async function aiAnalyze() {
const result = await resp.json(); const result = await resp.json();
aiHandleResult(result); aiHandleResult(result);
} catch (err) { } catch (err) {
clearTimeout(hardTimeout);
clearTimeout(slowWarning);
console.error('AI analyze error:', err); console.error('AI analyze error:', err);
aiSetLoading(false); aiSetLoading(false);
if (err.name === 'AbortError') {
aiShowError('The request timed out — your connection may be slow. Please try again.');
} else {
aiShowError(err.message); aiShowError(err.message);
} }
}
} }
async function aiSendFollowup() { async function aiSendFollowup() {
@@ -1340,15 +1392,24 @@ async function aiSendFollowup() {
}; };
const analyzeUrl = pageMeta.aiAnalyzeUrl || '/Quotes/AiAnalyzeItem'; const analyzeUrl = pageMeta.aiAnalyzeUrl || '/Quotes/AiAnalyzeItem';
const controller2 = new AbortController();
const hardTimeout2 = setTimeout(() => controller2.abort(), 120_000);
const slowWarning2 = setTimeout(() => {
const t = document.getElementById('ai_loadingText');
if (t) t.textContent = 'Still analyzing… this can take a minute on mobile connections.';
}, 30_000);
try { try {
const resp = await fetch(analyzeUrl, { const resp = await fetch(analyzeUrl, {
method: 'POST', method: 'POST',
signal: controller2.signal,
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
'RequestVerificationToken': document.querySelector('input[name="__RequestVerificationToken"]')?.value || '' 'RequestVerificationToken': document.querySelector('input[name="__RequestVerificationToken"]')?.value || ''
}, },
body: JSON.stringify(payload) body: JSON.stringify(payload)
}); });
clearTimeout(hardTimeout2);
clearTimeout(slowWarning2);
if (!resp.ok) { if (!resp.ok) {
if (resp.status === 401 || resp.status === 302 || resp.redirected) { if (resp.status === 401 || resp.status === 302 || resp.redirected) {
throw new Error('Your session has expired. Please refresh the page and sign in again.'); throw new Error('Your session has expired. Please refresh the page and sign in again.');
@@ -1362,10 +1423,16 @@ async function aiSendFollowup() {
const result = await resp.json(); const result = await resp.json();
aiHandleResult(result); aiHandleResult(result);
} catch (err) { } catch (err) {
clearTimeout(hardTimeout2);
clearTimeout(slowWarning2);
console.error('AI follow-up error:', err); console.error('AI follow-up error:', err);
aiSetLoading(false); aiSetLoading(false);
if (err.name === 'AbortError') {
aiShowError('The request timed out — your connection may be slow. Please try again.');
} else {
aiShowError(err.message); aiShowError(err.message);
} }
}
} }
function aiHandleResult(result) { function aiHandleResult(result) {
@@ -1451,6 +1518,8 @@ function aiSetLoading(isLoading) {
if (btn) btn.disabled = isLoading; if (btn) btn.disabled = isLoading;
spinner?.classList.toggle('d-none', !isLoading); spinner?.classList.toggle('d-none', !isLoading);
text?.classList.toggle('d-none', !isLoading); text?.classList.toggle('d-none', !isLoading);
// Reset text so a retry after the slow-connection warning shows the default message
if (!isLoading && text) text.textContent = 'Analyzing photos, please wait…';
} }
function aiShowError(message) { function aiShowError(message) {

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