Compare commits

..

15 Commits

Author SHA1 Message Date
spouliot 25140554ad Merge hotfixes: Stripe receipt_email, surcharge fix, void deposit/credit, cache headers
- Remove receipt_email from Stripe PaymentIntent (any email accepted at checkout)
- Fix surcharge payment: input/validation based on total-with-fee, not base amount
- Add InvariantCulture to payment JS literals
- Fix voided invoice leaving deposits locked (re-releases for next invoice)
- Convert non-deposit payments to CRED- credits on void (preserves money trail)
- Cache-Control: no-store on authenticated pages (prevents browser cache corruption)
- Fix Edit Payment onclick encoding for apostrophes in reference/notes

Inline item editing (7fa385a) held in dev pending further testing.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 14:19:10 -04:00
spouliot 46cadea367 Add Cache-Control: no-store for authenticated pages; fix payment onclick encoding
Prevents browsers from caching authenticated pages, which resolves stale/corrupt
cache bugs (e.g. Firefox refusing to navigate to a specific invoice). Also fixes
the Edit Payment button onclick to use Json.Serialize for Reference/Notes so
apostrophes and other special characters don't break the JavaScript string literal.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 14:18:04 -04:00
spouliot cfe937c0c3 Convert non-deposit payments to customer credits on invoice void
When voiding an invoice that has non-deposit payments (e.g. CC charges),
those payments are now converted to CRED- Deposit records so the money
trail is preserved and the credit auto-applies to the replacement invoice.
Deposits that were applied to the voided invoice are also re-released so
they can auto-apply again. Void confirmation dialog and success message
both reflect the credit amount when applicable.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 14:18:03 -04:00
spouliot 3ad6b0d08f Fix voided invoice leaving deposits locked as applied
When an invoice was voided, deposits auto-applied at invoice creation
kept their AppliedToInvoiceId pointing at the voided invoice. The
replacement invoice lookup (AppliedToInvoiceId == null) skipped them,
so the deposit was never re-applied and the customer was charged in full.

Void now clears AppliedToInvoiceId/AppliedDate on all deposits tied to
the invoice so they're available for the next invoice, and credits the
CustomerDeposits 2300 liability account to restore the balance that was
debited when the deposits were originally applied.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 14:18:01 -04:00
spouliot fdac0240d1 Fix Stripe receipt_email + online payment surcharge and hardening
Remove receipt_email from PaymentIntent creation so customers can use
any email at Stripe checkout without a stored-email mismatch blocking
payment. Remove now-dead CustomerEmail from PaymentPageViewModel.

Fix surcharge payment input: amount field now represents the total the
customer pays (including fee); JS back-calculates base before sending
to server. Add InvariantCulture to numeric Razor→JS literals to prevent
comma-decimal cultures from truncating surcharge values.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 14:17:57 -04:00
spouliot 8768e9813b Merge dev into master for release v2026.05.19b 2026-05-19 18:40:57 -04:00
spouliot 4a7087cc0c Fix NoExtraLayerCharge dropped in DeleteItem pricing recalculation
After deleting a job item, the remaining-items DTO projection was missing
NoExtraLayerCharge, causing PricingCalculationService to treat all coats as
extra-charge when recalculating the job total post-delete.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 18:37:29 -04:00
spouliot 59b152c89f Fix noExtraLayerCharge missing from Job Details wizard item projection
WizardExistingItems coat serialization in Details GET omitted noExtraLayerCharge,
so editing a line item from the Details page always lost the no-charge flag.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 18:36:04 -04:00
spouliot 441898b52f Fix NoExtraLayerCharge not persisting on quotes and job EditItems reload
- QuotePricingAssemblyService.BuildQuoteItemCoat: map NoExtraLayerCharge from
  CreateQuoteItemCoatDto to QuoteItemCoat on every quote save (was always omitted)
- JobsController.EditItems GET: include NoExtraLayerCharge in coat mapping when
  reloading existing items for the wizard (was dropped, causing revert on second edit)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 18:33:50 -04:00
spouliot 3e30397302 Sync master back to dev (IF EXISTS migration hotfix) 2026-05-19 18:24:39 -04:00
spouliot 31c5746e5b Guard ShopWorker drops in AddAppointmentReminderSentAt migration with IF EXISTS
Prod and dev databases diverged on whether ShopWorker tables and indexes
exist, causing unconditional DROP statements to fail on prod. Replaced
all individual DropForeignKey/DropTable/DropIndex/DropColumn calls with
a single SQL block using IF EXISTS guards so the migration runs safely
regardless of DB state.

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 15:48:16 -04:00
36 changed files with 39409 additions and 267 deletions
@@ -137,6 +137,13 @@ public class CreateJobDto
[Display(Name = "Oven")] [Display(Name = "Oven")]
public int? OvenCostId { get; set; } public int? OvenCostId { get; set; }
[Display(Name = "Batches")]
[Range(1, 999)]
public int OvenBatches { get; set; } = 1;
[Display(Name = "Cycle Time (min)")]
public int? OvenCycleMinutes { get; set; }
[Required(ErrorMessage = "Description is required")] [Required(ErrorMessage = "Description is required")]
[StringLength(2000, ErrorMessage = "Description cannot exceed 2000 characters")] [StringLength(2000, ErrorMessage = "Description cannot exceed 2000 characters")]
[Display(Name = "Description")] [Display(Name = "Description")]
@@ -208,6 +215,16 @@ public class UpdateJobDto
[Display(Name = "Assigned Worker")] [Display(Name = "Assigned Worker")]
public string? AssignedUserId { get; set; } public string? AssignedUserId { get; set; }
[Display(Name = "Oven")]
public int? OvenCostId { get; set; }
[Display(Name = "Batches")]
[Range(1, 999)]
public int OvenBatches { get; set; } = 1;
[Display(Name = "Cycle Time (min)")]
public int? OvenCycleMinutes { get; set; }
[Required(ErrorMessage = "Description is required")] [Required(ErrorMessage = "Description is required")]
[StringLength(2000, ErrorMessage = "Description cannot exceed 2000 characters")] [StringLength(2000, ErrorMessage = "Description cannot exceed 2000 characters")]
[Display(Name = "Description")] [Display(Name = "Description")]
@@ -381,6 +398,7 @@ public class JobItemCoatDto
public decimal? PowderCostPerLb { get; set; } public decimal? PowderCostPerLb { get; set; }
public decimal? PowderToOrder { get; set; } public decimal? PowderToOrder { get; set; }
public decimal? ActualPowderUsedLbs { get; set; } // Filled during job completion public decimal? ActualPowderUsedLbs { get; set; } // Filled during job completion
public bool NoExtraLayerCharge { get; set; }
public string? Notes { get; set; } public string? Notes { get; set; }
} }
@@ -801,6 +801,7 @@ public class QuoteItemCoatDto
public decimal CoatMaterialCost { get; set; } public decimal CoatMaterialCost { get; set; }
public decimal CoatLaborCost { get; set; } public decimal CoatLaborCost { get; set; }
public decimal CoatTotalCost { get; set; } public decimal CoatTotalCost { get; set; }
public bool NoExtraLayerCharge { get; set; }
public string? Notes { get; set; } public string? Notes { get; set; }
} }
@@ -91,4 +91,11 @@ public interface INotificationService
/// Alert company staff when a Stripe chargeback (dispute) is opened on an invoice payment. /// Alert company staff when a Stripe chargeback (dispute) is opened on an invoice payment.
/// </summary> /// </summary>
Task NotifyChargebackAlertAsync(Invoice invoice, string disputeId, decimal amount, string reason); Task NotifyChargebackAlertAsync(Invoice invoice, string disputeId, decimal amount, string reason);
/// <summary>
/// Sends an appointment reminder email to the linked customer (if opted in) and writes a
/// notification log row. Called by <see cref="PowderCoating.Web.BackgroundServices.AppointmentReminderBackgroundService"/>
/// when the reminder window opens. In-app bell notification is handled by the caller.
/// </summary>
Task NotifyAppointmentReminderAsync(Appointment appointment);
} }
@@ -20,7 +20,6 @@ public interface IStripeConnectService
decimal invoiceTotal, decimal invoiceTotal,
decimal surchargeAmount, decimal surchargeAmount,
string currency, string currency,
string customerEmail,
string invoiceNumber, string invoiceNumber,
int invoiceId); int invoiceId);
@@ -33,7 +32,6 @@ public interface IStripeConnectService
decimal depositAmount, decimal depositAmount,
decimal surchargeAmount, decimal surchargeAmount,
string currency, string currency,
string customerEmail,
string quoteNumber, string quoteNumber,
int quoteId); int quoteId);
} }
@@ -85,7 +85,8 @@ public class JobItemAssemblyService : IJobItemAssemblyService
TransferEfficiency = c.TransferEfficiency, TransferEfficiency = c.TransferEfficiency,
PowderCostPerLb = c.PowderCostPerLb, PowderCostPerLb = c.PowderCostPerLb,
PowderToOrder = CalculatePowderToOrder(c.PowderToOrder, source.SurfaceAreaSqFt, source.Quantity, c.CoverageSqFtPerLb, c.TransferEfficiency), PowderToOrder = CalculatePowderToOrder(c.PowderToOrder, source.SurfaceAreaSqFt, source.Quantity, c.CoverageSqFtPerLb, c.TransferEfficiency),
Notes = c.Notes Notes = c.Notes,
NoExtraLayerCharge = c.NoExtraLayerCharge
}, },
jobItemId, jobItemId,
companyId, companyId,
@@ -192,7 +193,8 @@ public class JobItemAssemblyService : IJobItemAssemblyService
TransferEfficiency = c.TransferEfficiency, TransferEfficiency = c.TransferEfficiency,
PowderCostPerLb = c.PowderCostPerLb, PowderCostPerLb = c.PowderCostPerLb,
PowderToOrder = CalculatePowderToOrder(c.PowderToOrder, source.SurfaceAreaSqFt, source.Quantity, c.CoverageSqFtPerLb, c.TransferEfficiency), PowderToOrder = CalculatePowderToOrder(c.PowderToOrder, source.SurfaceAreaSqFt, source.Quantity, c.CoverageSqFtPerLb, c.TransferEfficiency),
Notes = c.Notes Notes = c.Notes,
NoExtraLayerCharge = c.NoExtraLayerCharge
}, },
jobItemId, jobItemId,
companyId, companyId,
@@ -289,7 +291,8 @@ public class JobItemAssemblyService : IJobItemAssemblyService
TransferEfficiency = c.TransferEfficiency, TransferEfficiency = c.TransferEfficiency,
PowderCostPerLb = c.PowderCostPerLb, PowderCostPerLb = c.PowderCostPerLb,
PowderToOrder = c.PowderToOrder, PowderToOrder = c.PowderToOrder,
Notes = c.Notes Notes = c.Notes,
NoExtraLayerCharge = c.NoExtraLayerCharge
}, },
jobItemId, jobItemId,
companyId, companyId,
@@ -375,6 +378,7 @@ public class JobItemAssemblyService : IJobItemAssemblyService
PowderCostPerLb = seed.PowderCostPerLb, PowderCostPerLb = seed.PowderCostPerLb,
PowderToOrder = seed.PowderToOrder, PowderToOrder = seed.PowderToOrder,
Notes = seed.Notes, Notes = seed.Notes,
NoExtraLayerCharge = seed.NoExtraLayerCharge,
CompanyId = companyId, CompanyId = companyId,
CreatedAt = createdAtUtc CreatedAt = createdAtUtc
}; };
@@ -493,6 +497,7 @@ public class JobItemAssemblyService : IJobItemAssemblyService
public decimal? PowderCostPerLb { get; init; } public decimal? PowderCostPerLb { get; init; }
public decimal? PowderToOrder { get; init; } public decimal? PowderToOrder { get; init; }
public string? Notes { get; init; } public string? Notes { get; init; }
public bool NoExtraLayerCharge { get; init; }
} }
/// <summary>Intermediate value object for prep service creation — see <see cref="JobItemSeed"/> for rationale.</summary> /// <summary>Intermediate value object for prep service creation — see <see cref="JobItemSeed"/> for rationale.</summary>
@@ -264,6 +264,7 @@ public class QuotePricingAssemblyService : IQuotePricingAssemblyService
TransferEfficiency = coatDto.TransferEfficiency, TransferEfficiency = coatDto.TransferEfficiency,
PowderCostPerLb = coatDto.PowderCostPerLb, PowderCostPerLb = coatDto.PowderCostPerLb,
PowderToOrder = coatDto.PowderToOrder, PowderToOrder = coatDto.PowderToOrder,
NoExtraLayerCharge = coatDto.NoExtraLayerCharge,
Notes = coatDto.Notes, Notes = coatDto.Notes,
CompanyId = companyId, CompanyId = companyId,
CreatedAt = createdAtUtc CreatedAt = createdAtUtc
@@ -95,6 +95,12 @@ public class Appointment : BaseEntity
/// </summary> /// </summary>
public int ReminderMinutesBefore { get; set; } = 30; public int ReminderMinutesBefore { get; set; } = 30;
/// <summary>
/// UTC timestamp when the reminder was dispatched. Null means it hasn't fired yet.
/// The background service uses this as a deduplication guard to prevent double-sending.
/// </summary>
public DateTime? ReminderSentAt { get; set; }
// Navigation Properties // Navigation Properties
public virtual Customer? Customer { get; set; } public virtual Customer? Customer { get; set; }
public virtual Job? Job { get; set; } public virtual Job? Job { get; set; }
@@ -42,6 +42,13 @@ public class JobItemCoat : BaseEntity
public string? PowderReceivedByUserId { get; set; } public string? PowderReceivedByUserId { get; set; }
public decimal? PowderReceivedLbs { get; set; } public decimal? PowderReceivedLbs { get; set; }
// Pricing flags
/// <summary>
/// When true, the additional layer labor charge is not applied for this coat even if it is
/// not the first coat in the sequence. Used for clear coats, sealers, etc.
/// </summary>
public bool NoExtraLayerCharge { get; set; }
// Notes // Notes
public string? Notes { get; set; } public string? Notes { get; set; }
@@ -33,6 +33,13 @@ public class QuoteItemCoat : BaseEntity
public decimal CoatLaborCost { get; set; } public decimal CoatLaborCost { get; set; }
public decimal CoatTotalCost { get; set; } public decimal CoatTotalCost { get; set; }
// Pricing flags
/// <summary>
/// When true, the additional layer labor charge is not applied for this coat even if it is
/// not the first coat in the sequence. Used for clear coats, sealers, etc.
/// </summary>
public bool NoExtraLayerCharge { get; set; }
// Notes // Notes
public string? Notes { get; set; } public string? Notes { get; set; }
@@ -20,5 +20,7 @@ public enum NotificationType
SmsInboundStop = 12, SmsInboundStop = 12,
SmsInboundHelp = 13, SmsInboundHelp = 13,
AdminEmail = 14, AdminEmail = 14,
SmsInboundStart = 15 SmsInboundStart = 15,
AppointmentReminder = 17,
AppointmentReminderStaff = 18
} }
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -11,26 +11,20 @@ namespace PowderCoating.Infrastructure.Migrations
/// <inheritdoc /> /// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder) protected override void Up(MigrationBuilder migrationBuilder)
{ {
migrationBuilder.UpdateData( // These UpdateData calls were generated from an existing live database.
table: "PricingTiers", // On a fresh install the PricingTiers table and its seed rows may not exist yet
keyColumn: "Id", // (seeding is manual via Platform Management → Seed Data), so guard each update.
keyValue: 1, migrationBuilder.Sql(@"
column: "CreatedAt", IF OBJECT_ID(N'[PricingTiers]', N'U') IS NOT NULL
value: new DateTime(2026, 3, 16, 15, 49, 58, 737, DateTimeKind.Utc).AddTicks(7851)); BEGIN
IF EXISTS (SELECT 1 FROM [PricingTiers] WHERE [Id] = 1)
migrationBuilder.UpdateData( UPDATE [PricingTiers] SET [CreatedAt] = '2026-03-16T15:49:58.7377851Z' WHERE [Id] = 1;
table: "PricingTiers", IF EXISTS (SELECT 1 FROM [PricingTiers] WHERE [Id] = 2)
keyColumn: "Id", UPDATE [PricingTiers] SET [CreatedAt] = '2026-03-16T15:49:58.7377856Z' WHERE [Id] = 2;
keyValue: 2, IF EXISTS (SELECT 1 FROM [PricingTiers] WHERE [Id] = 3)
column: "CreatedAt", UPDATE [PricingTiers] SET [CreatedAt] = '2026-03-16T15:49:58.7377858Z' WHERE [Id] = 3;
value: new DateTime(2026, 3, 16, 15, 49, 58, 737, DateTimeKind.Utc).AddTicks(7856)); END
");
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 3,
column: "CreatedAt",
value: new DateTime(2026, 3, 16, 15, 49, 58, 737, DateTimeKind.Utc).AddTicks(7858));
} }
/// <inheritdoc /> /// <inheritdoc />
@@ -0,0 +1,217 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace PowderCoating.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class AddAppointmentReminderSentAt : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
// Use IF EXISTS guards for all ShopWorker drops — prod and dev diverged on whether
// these objects exist, so unconditional drops would fail on whichever DB is missing them.
migrationBuilder.Sql(@"
IF EXISTS (SELECT 1 FROM sys.foreign_keys WHERE name = 'FK_Jobs_ShopWorkers_ShopWorkerId')
ALTER TABLE [Jobs] DROP CONSTRAINT [FK_Jobs_ShopWorkers_ShopWorkerId];
IF EXISTS (SELECT 1 FROM sys.foreign_keys WHERE name = 'FK_JobTimeEntries_ShopWorkers_ShopWorkerId')
ALTER TABLE [JobTimeEntries] DROP CONSTRAINT [FK_JobTimeEntries_ShopWorkers_ShopWorkerId];
IF EXISTS (SELECT 1 FROM sys.foreign_keys WHERE name = 'FK_MaintenanceRecords_ShopWorkers_ShopWorkerId')
ALTER TABLE [MaintenanceRecords] DROP CONSTRAINT [FK_MaintenanceRecords_ShopWorkers_ShopWorkerId];
IF EXISTS (SELECT 1 FROM sys.tables WHERE name = 'ShopWorkerRoleCosts')
DROP TABLE [ShopWorkerRoleCosts];
IF EXISTS (SELECT 1 FROM sys.tables WHERE name = 'ShopWorkers')
DROP TABLE [ShopWorkers];
IF EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'IX_MaintenanceRecords_ShopWorkerId' AND object_id = OBJECT_ID('MaintenanceRecords'))
DROP INDEX [IX_MaintenanceRecords_ShopWorkerId] ON [MaintenanceRecords];
IF EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'IX_JobTimeEntries_ShopWorkerId' AND object_id = OBJECT_ID('JobTimeEntries'))
DROP INDEX [IX_JobTimeEntries_ShopWorkerId] ON [JobTimeEntries];
IF EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'IX_Jobs_ShopWorkerId' AND object_id = OBJECT_ID('Jobs'))
DROP INDEX [IX_Jobs_ShopWorkerId] ON [Jobs];
IF EXISTS (SELECT 1 FROM sys.columns WHERE name = 'ShopWorkerId' AND object_id = OBJECT_ID('MaintenanceRecords'))
ALTER TABLE [MaintenanceRecords] DROP COLUMN [ShopWorkerId];
IF EXISTS (SELECT 1 FROM sys.columns WHERE name = 'ShopWorkerId' AND object_id = OBJECT_ID('JobTimeEntries'))
ALTER TABLE [JobTimeEntries] DROP COLUMN [ShopWorkerId];
IF EXISTS (SELECT 1 FROM sys.columns WHERE name = 'ShopWorkerId' AND object_id = OBJECT_ID('Jobs'))
ALTER TABLE [Jobs] DROP COLUMN [ShopWorkerId];
");
migrationBuilder.AddColumn<DateTime>(
name: "ReminderSentAt",
table: "Appointments",
type: "datetime2",
nullable: true);
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 1,
column: "CreatedAt",
value: new DateTime(2026, 5, 19, 15, 12, 57, 355, DateTimeKind.Utc).AddTicks(2970));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 2,
column: "CreatedAt",
value: new DateTime(2026, 5, 19, 15, 12, 57, 355, DateTimeKind.Utc).AddTicks(2976));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 3,
column: "CreatedAt",
value: new DateTime(2026, 5, 19, 15, 12, 57, 355, DateTimeKind.Utc).AddTicks(2977));
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "ReminderSentAt",
table: "Appointments");
migrationBuilder.AddColumn<int>(
name: "ShopWorkerId",
table: "MaintenanceRecords",
type: "int",
nullable: true);
migrationBuilder.AddColumn<int>(
name: "ShopWorkerId",
table: "JobTimeEntries",
type: "int",
nullable: true);
migrationBuilder.AddColumn<int>(
name: "ShopWorkerId",
table: "Jobs",
type: "int",
nullable: true);
migrationBuilder.CreateTable(
name: "ShopWorkerRoleCosts",
columns: table => new
{
Id = table.Column<int>(type: "int", nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
CompanyId = table.Column<int>(type: "int", nullable: false),
CreatedAt = table.Column<DateTime>(type: "datetime2", nullable: false),
CreatedBy = table.Column<string>(type: "nvarchar(max)", nullable: true),
DeletedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
DeletedBy = table.Column<string>(type: "nvarchar(max)", nullable: true),
HourlyRate = table.Column<decimal>(type: "decimal(18,2)", nullable: false),
IsDeleted = table.Column<bool>(type: "bit", nullable: false),
Role = table.Column<int>(type: "int", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
UpdatedBy = table.Column<string>(type: "nvarchar(max)", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_ShopWorkerRoleCosts", x => x.Id);
});
migrationBuilder.CreateTable(
name: "ShopWorkers",
columns: table => new
{
Id = table.Column<int>(type: "int", nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
CompanyId = table.Column<int>(type: "int", nullable: false),
CreatedAt = table.Column<DateTime>(type: "datetime2", nullable: false),
CreatedBy = table.Column<string>(type: "nvarchar(max)", nullable: true),
DeletedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
DeletedBy = table.Column<string>(type: "nvarchar(max)", nullable: true),
Email = table.Column<string>(type: "nvarchar(max)", nullable: true),
IsActive = table.Column<bool>(type: "bit", nullable: false),
IsDeleted = table.Column<bool>(type: "bit", nullable: false),
Name = table.Column<string>(type: "nvarchar(max)", nullable: false),
Notes = table.Column<string>(type: "nvarchar(max)", nullable: true),
Phone = table.Column<string>(type: "nvarchar(max)", nullable: true),
Role = table.Column<int>(type: "int", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
UpdatedBy = table.Column<string>(type: "nvarchar(max)", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_ShopWorkers", x => x.Id);
table.ForeignKey(
name: "FK_ShopWorkers_Companies_CompanyId",
column: x => x.CompanyId,
principalTable: "Companies",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
});
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 1,
column: "CreatedAt",
value: new DateTime(2026, 5, 15, 23, 44, 10, 471, DateTimeKind.Utc).AddTicks(3131));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 2,
column: "CreatedAt",
value: new DateTime(2026, 5, 15, 23, 44, 10, 471, DateTimeKind.Utc).AddTicks(3137));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 3,
column: "CreatedAt",
value: new DateTime(2026, 5, 15, 23, 44, 10, 471, DateTimeKind.Utc).AddTicks(3138));
migrationBuilder.CreateIndex(
name: "IX_MaintenanceRecords_ShopWorkerId",
table: "MaintenanceRecords",
column: "ShopWorkerId");
migrationBuilder.CreateIndex(
name: "IX_JobTimeEntries_ShopWorkerId",
table: "JobTimeEntries",
column: "ShopWorkerId");
migrationBuilder.CreateIndex(
name: "IX_Jobs_ShopWorkerId",
table: "Jobs",
column: "ShopWorkerId");
migrationBuilder.CreateIndex(
name: "IX_ShopWorkerRoleCosts_CompanyId_Role",
table: "ShopWorkerRoleCosts",
columns: new[] { "CompanyId", "Role" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_ShopWorkers_CompanyId",
table: "ShopWorkers",
column: "CompanyId");
migrationBuilder.AddForeignKey(
name: "FK_Jobs_ShopWorkers_ShopWorkerId",
table: "Jobs",
column: "ShopWorkerId",
principalTable: "ShopWorkers",
principalColumn: "Id");
migrationBuilder.AddForeignKey(
name: "FK_JobTimeEntries_ShopWorkers_ShopWorkerId",
table: "JobTimeEntries",
column: "ShopWorkerId",
principalTable: "ShopWorkers",
principalColumn: "Id");
migrationBuilder.AddForeignKey(
name: "FK_MaintenanceRecords_ShopWorkers_ShopWorkerId",
table: "MaintenanceRecords",
column: "ShopWorkerId",
principalTable: "ShopWorkers",
principalColumn: "Id");
}
}
}
@@ -0,0 +1,83 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace PowderCoating.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class AddNoExtraLayerChargeToCoats : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "NoExtraLayerCharge",
table: "QuoteItemCoats",
type: "bit",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<bool>(
name: "NoExtraLayerCharge",
table: "JobItemCoats",
type: "bit",
nullable: false,
defaultValue: false);
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 1,
column: "CreatedAt",
value: new DateTime(2026, 5, 19, 19, 26, 9, 226, DateTimeKind.Utc).AddTicks(5186));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 2,
column: "CreatedAt",
value: new DateTime(2026, 5, 19, 19, 26, 9, 226, DateTimeKind.Utc).AddTicks(5190));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 3,
column: "CreatedAt",
value: new DateTime(2026, 5, 19, 19, 26, 9, 226, DateTimeKind.Utc).AddTicks(5191));
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "NoExtraLayerCharge",
table: "QuoteItemCoats");
migrationBuilder.DropColumn(
name: "NoExtraLayerCharge",
table: "JobItemCoats");
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 1,
column: "CreatedAt",
value: new DateTime(2026, 5, 19, 15, 44, 18, 742, DateTimeKind.Utc).AddTicks(3960));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 2,
column: "CreatedAt",
value: new DateTime(2026, 5, 19, 15, 44, 18, 742, DateTimeKind.Utc).AddTicks(3966));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 3,
column: "CreatedAt",
value: new DateTime(2026, 5, 19, 15, 44, 18, 742, DateTimeKind.Utc).AddTicks(3967));
}
}
}
@@ -716,6 +716,9 @@ namespace PowderCoating.Infrastructure.Migrations
b.Property<int>("ReminderMinutesBefore") b.Property<int>("ReminderMinutesBefore")
.HasColumnType("int"); .HasColumnType("int");
b.Property<DateTime?>("ReminderSentAt")
.HasColumnType("datetime2");
b.Property<DateTime>("ScheduledEndTime") b.Property<DateTime>("ScheduledEndTime")
.HasColumnType("datetime2"); .HasColumnType("datetime2");
@@ -4252,9 +4255,6 @@ namespace PowderCoating.Infrastructure.Migrations
b.Property<decimal>("ShopSuppliesPercent") b.Property<decimal>("ShopSuppliesPercent")
.HasColumnType("decimal(18,2)"); .HasColumnType("decimal(18,2)");
b.Property<int?>("ShopWorkerId")
.HasColumnType("int");
b.Property<string>("SpecialInstructions") b.Property<string>("SpecialInstructions")
.HasColumnType("nvarchar(max)"); .HasColumnType("nvarchar(max)");
@@ -4296,8 +4296,6 @@ namespace PowderCoating.Infrastructure.Migrations
b.HasIndex("ScheduledDate"); b.HasIndex("ScheduledDate");
b.HasIndex("ShopWorkerId");
b.HasIndex("CompanyId", "CustomerId") b.HasIndex("CompanyId", "CustomerId")
.HasDatabaseName("IX_Jobs_CompanyId_CustomerId"); .HasDatabaseName("IX_Jobs_CompanyId_CustomerId");
@@ -4620,6 +4618,9 @@ namespace PowderCoating.Infrastructure.Migrations
b.Property<int>("JobItemId") b.Property<int>("JobItemId")
.HasColumnType("int"); .HasColumnType("int");
b.Property<bool>("NoExtraLayerCharge")
.HasColumnType("bit");
b.Property<string>("Notes") b.Property<string>("Notes")
.HasColumnType("nvarchar(max)"); .HasColumnType("nvarchar(max)");
@@ -5439,9 +5440,6 @@ namespace PowderCoating.Infrastructure.Migrations
b.Property<string>("Notes") b.Property<string>("Notes")
.HasColumnType("nvarchar(max)"); .HasColumnType("nvarchar(max)");
b.Property<int?>("ShopWorkerId")
.HasColumnType("int");
b.Property<string>("Stage") b.Property<string>("Stage")
.HasColumnType("nvarchar(max)"); .HasColumnType("nvarchar(max)");
@@ -5464,8 +5462,6 @@ namespace PowderCoating.Infrastructure.Migrations
b.HasIndex("JobId"); b.HasIndex("JobId");
b.HasIndex("ShopWorkerId");
b.ToTable("JobTimeEntries"); b.ToTable("JobTimeEntries");
}); });
@@ -5789,9 +5785,6 @@ namespace PowderCoating.Infrastructure.Migrations
b.Property<DateTime>("ScheduledDate") b.Property<DateTime>("ScheduledDate")
.HasColumnType("datetime2"); .HasColumnType("datetime2");
b.Property<int?>("ShopWorkerId")
.HasColumnType("int");
b.Property<int>("Status") b.Property<int>("Status")
.HasColumnType("int"); .HasColumnType("int");
@@ -5822,8 +5815,6 @@ namespace PowderCoating.Infrastructure.Migrations
b.HasIndex("ScheduledDate"); b.HasIndex("ScheduledDate");
b.HasIndex("ShopWorkerId");
b.HasIndex("Status"); b.HasIndex("Status");
b.HasIndex("CompanyId", "ScheduledDate") b.HasIndex("CompanyId", "ScheduledDate")
@@ -6720,7 +6711,7 @@ namespace PowderCoating.Infrastructure.Migrations
{ {
Id = 1, Id = 1,
CompanyId = 0, CompanyId = 0,
CreatedAt = new DateTime(2026, 5, 15, 23, 44, 10, 471, DateTimeKind.Utc).AddTicks(3131), CreatedAt = new DateTime(2026, 5, 19, 19, 26, 9, 226, DateTimeKind.Utc).AddTicks(5186),
Description = "Standard pricing for regular customers", Description = "Standard pricing for regular customers",
DiscountPercent = 0m, DiscountPercent = 0m,
IsActive = true, IsActive = true,
@@ -6731,7 +6722,7 @@ namespace PowderCoating.Infrastructure.Migrations
{ {
Id = 2, Id = 2,
CompanyId = 0, CompanyId = 0,
CreatedAt = new DateTime(2026, 5, 15, 23, 44, 10, 471, DateTimeKind.Utc).AddTicks(3137), CreatedAt = new DateTime(2026, 5, 19, 19, 26, 9, 226, DateTimeKind.Utc).AddTicks(5190),
Description = "5% discount for preferred customers", Description = "5% discount for preferred customers",
DiscountPercent = 5m, DiscountPercent = 5m,
IsActive = true, IsActive = true,
@@ -6742,7 +6733,7 @@ namespace PowderCoating.Infrastructure.Migrations
{ {
Id = 3, Id = 3,
CompanyId = 0, CompanyId = 0,
CreatedAt = new DateTime(2026, 5, 15, 23, 44, 10, 471, DateTimeKind.Utc).AddTicks(3138), CreatedAt = new DateTime(2026, 5, 19, 19, 26, 9, 226, DateTimeKind.Utc).AddTicks(5191),
Description = "10% discount for premium customers", Description = "10% discount for premium customers",
DiscountPercent = 10m, DiscountPercent = 10m,
IsActive = true, IsActive = true,
@@ -7417,6 +7408,9 @@ namespace PowderCoating.Infrastructure.Migrations
b.Property<bool>("IsDeleted") b.Property<bool>("IsDeleted")
.HasColumnType("bit"); .HasColumnType("bit");
b.Property<bool>("NoExtraLayerCharge")
.HasColumnType("bit");
b.Property<string>("Notes") b.Property<string>("Notes")
.HasColumnType("nvarchar(max)"); .HasColumnType("nvarchar(max)");
@@ -8019,111 +8013,6 @@ namespace PowderCoating.Infrastructure.Migrations
b.ToTable("ReworkRecords"); b.ToTable("ReworkRecords");
}); });
modelBuilder.Entity("PowderCoating.Core.Entities.ShopWorker", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<int>("CompanyId")
.HasColumnType("int");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime2");
b.Property<string>("CreatedBy")
.HasColumnType("nvarchar(max)");
b.Property<DateTime?>("DeletedAt")
.HasColumnType("datetime2");
b.Property<string>("DeletedBy")
.HasColumnType("nvarchar(max)");
b.Property<string>("Email")
.HasColumnType("nvarchar(max)");
b.Property<bool>("IsActive")
.HasColumnType("bit");
b.Property<bool>("IsDeleted")
.HasColumnType("bit");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<string>("Notes")
.HasColumnType("nvarchar(max)");
b.Property<string>("Phone")
.HasColumnType("nvarchar(max)");
b.Property<int>("Role")
.HasColumnType("int");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("datetime2");
b.Property<string>("UpdatedBy")
.HasColumnType("nvarchar(max)");
b.HasKey("Id");
b.HasIndex("CompanyId");
b.ToTable("ShopWorkers");
});
modelBuilder.Entity("PowderCoating.Core.Entities.ShopWorkerRoleCost", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<int>("CompanyId")
.HasColumnType("int");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime2");
b.Property<string>("CreatedBy")
.HasColumnType("nvarchar(max)");
b.Property<DateTime?>("DeletedAt")
.HasColumnType("datetime2");
b.Property<string>("DeletedBy")
.HasColumnType("nvarchar(max)");
b.Property<decimal>("HourlyRate")
.HasColumnType("decimal(18,2)");
b.Property<bool>("IsDeleted")
.HasColumnType("bit");
b.Property<int>("Role")
.HasColumnType("int");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("datetime2");
b.Property<string>("UpdatedBy")
.HasColumnType("nvarchar(max)");
b.HasKey("Id");
b.HasIndex("CompanyId", "Role")
.IsUnique()
.HasDatabaseName("IX_ShopWorkerRoleCosts_CompanyId_Role");
b.ToTable("ShopWorkerRoleCosts");
});
modelBuilder.Entity("PowderCoating.Core.Entities.StripeWebhookEvent", b => modelBuilder.Entity("PowderCoating.Core.Entities.StripeWebhookEvent", b =>
{ {
b.Property<long>("Id") b.Property<long>("Id")
@@ -9541,10 +9430,6 @@ namespace PowderCoating.Infrastructure.Migrations
.HasForeignKey("PowderCoating.Core.Entities.Job", "QuoteId") .HasForeignKey("PowderCoating.Core.Entities.Job", "QuoteId")
.OnDelete(DeleteBehavior.SetNull); .OnDelete(DeleteBehavior.SetNull);
b.HasOne("PowderCoating.Core.Entities.ShopWorker", null)
.WithMany("AssignedJobs")
.HasForeignKey("ShopWorkerId");
b.Navigation("AssignedUser"); b.Navigation("AssignedUser");
b.Navigation("Customer"); b.Navigation("Customer");
@@ -9847,13 +9732,7 @@ namespace PowderCoating.Infrastructure.Migrations
.OnDelete(DeleteBehavior.Cascade) .OnDelete(DeleteBehavior.Cascade)
.IsRequired(); .IsRequired();
b.HasOne("PowderCoating.Core.Entities.ShopWorker", "Worker")
.WithMany("TimeEntries")
.HasForeignKey("ShopWorkerId");
b.Navigation("Job"); b.Navigation("Job");
b.Navigation("Worker");
}); });
modelBuilder.Entity("PowderCoating.Core.Entities.JournalEntry", b => modelBuilder.Entity("PowderCoating.Core.Entities.JournalEntry", b =>
@@ -9924,10 +9803,6 @@ namespace PowderCoating.Infrastructure.Migrations
.WithMany() .WithMany()
.HasForeignKey("RecurrenceParentId"); .HasForeignKey("RecurrenceParentId");
b.HasOne("PowderCoating.Core.Entities.ShopWorker", null)
.WithMany("AssignedMaintenanceTasks")
.HasForeignKey("ShopWorkerId");
b.Navigation("AssignedUser"); b.Navigation("AssignedUser");
b.Navigation("Equipment"); b.Navigation("Equipment");
@@ -10411,15 +10286,6 @@ namespace PowderCoating.Infrastructure.Migrations
b.Navigation("ReworkJob"); b.Navigation("ReworkJob");
}); });
modelBuilder.Entity("PowderCoating.Core.Entities.ShopWorker", b =>
{
b.HasOne("PowderCoating.Core.Entities.Company", null)
.WithMany("ShopWorkers")
.HasForeignKey("CompanyId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
});
modelBuilder.Entity("PowderCoating.Core.Entities.Vendor", b => modelBuilder.Entity("PowderCoating.Core.Entities.Vendor", b =>
{ {
b.HasOne("PowderCoating.Core.Entities.Company", null) b.HasOne("PowderCoating.Core.Entities.Company", null)
@@ -10582,8 +10448,6 @@ namespace PowderCoating.Infrastructure.Migrations
b.Navigation("Quotes"); b.Navigation("Quotes");
b.Navigation("ShopWorkers");
b.Navigation("Users"); b.Navigation("Users");
b.Navigation("Vendors"); b.Navigation("Vendors");
@@ -10749,15 +10613,6 @@ namespace PowderCoating.Infrastructure.Migrations
b.Navigation("Quotes"); b.Navigation("Quotes");
}); });
modelBuilder.Entity("PowderCoating.Core.Entities.ShopWorker", b =>
{
b.Navigation("AssignedJobs");
b.Navigation("AssignedMaintenanceTasks");
b.Navigation("TimeEntries");
});
modelBuilder.Entity("PowderCoating.Core.Entities.Vendor", b => modelBuilder.Entity("PowderCoating.Core.Entities.Vendor", b =>
{ {
b.Navigation("BillPayments"); b.Navigation("BillPayments");
@@ -1152,6 +1152,156 @@ public class NotificationService : INotificationService
}; };
} }
/// <summary>
/// Sends appointment reminder emails when an appointment's reminder window opens.
/// Two emails are dispatched independently:
/// <list type="bullet">
/// <item>Customer email — sent when a customer is linked, has an email address, and has
/// email notifications enabled (<see cref="Customer.NotifyByEmail"/>).</item>
/// <item>Staff email — sent to <see cref="BaseEntity.CreatedBy"/> (the user who created
/// the appointment). This fires regardless of whether a customer is linked.</item>
/// </list>
/// Called exclusively by
/// <see cref="PowderCoating.Web.BackgroundServices.AppointmentReminderBackgroundService"/>
/// after it stamps <c>ReminderSentAt</c> — the caller owns deduplication.
/// </summary>
public async Task NotifyAppointmentReminderAsync(Appointment appointment)
{
try
{
var (companyName, company) = await GetCompanyAsync(appointment.CompanyId);
var (replyToEmail, replyToName) = await GetEmailFromAsync(appointment.CompanyId);
var baseUrl = await GetBaseUrlAsync();
var locationLine = !string.IsNullOrWhiteSpace(appointment.Location)
? $"<br/><strong>Location:</strong> {WebUtility.HtmlEncode(appointment.Location)}"
: string.Empty;
var appointmentDate = appointment.ScheduledStartTime.ToString("dddd, MMMM d, yyyy");
var appointmentTime = appointment.IsAllDay
? "All Day"
: appointment.ScheduledStartTime.ToString("h:mm tt");
var defaultSubject = $"Appointment Reminder — {appointment.Title} on {appointment.ScheduledStartTime:MMMM d, yyyy}";
// ── Customer email ────────────────────────────────────────────────
if (appointment.CustomerId != null)
{
var customer = appointment.Customer
?? await _context.Customers.FindAsync(appointment.CustomerId.Value);
if (customer != null)
{
var customerName = GetCustomerDisplayName(customer);
var reminderEmails = ParseEmailList(customer.Email);
if (!customer.NotifyByEmail || reminderEmails.Count == 0)
{
if (reminderEmails.Count > 0)
{
await WriteLog(SkippedLog(NotificationChannel.Email, NotificationType.AppointmentReminder,
customerName, string.Join(", ", reminderEmails), appointment.CompanyId,
customerId: customer.Id));
}
}
else
{
var customerValues = new Dictionary<string, string>
{
["companyName"] = companyName,
["customerName"] = customerName,
["appointmentTitle"] = appointment.Title,
["appointmentDate"] = appointmentDate,
["appointmentTime"] = appointmentTime,
["locationLine"] = locationLine
};
var (custSubject, custHtml) = await GetRenderedEmailAsync(
appointment.CompanyId, NotificationType.AppointmentReminder, customerValues, defaultSubject);
var custFullHtml = AppendUnsubscribeFooterHtml(custHtml, customer.UnsubscribeToken, company, baseUrl);
var custPlainText = StripHtml(custFullHtml);
var (custOk, custErr, custLog) = await SendToEmailListAsync(
customer.Email, customerName, custSubject, custPlainText, custFullHtml,
replyToEmail: replyToEmail, replyToName: replyToName);
await WriteLog(new NotificationLog
{
Channel = NotificationChannel.Email,
NotificationType = NotificationType.AppointmentReminder,
Status = custOk ? NotificationStatus.Sent : NotificationStatus.Failed,
RecipientName = customerName,
Recipient = custLog,
Subject = custSubject,
Message = custPlainText,
ErrorMessage = custErr,
SentAt = DateTime.UtcNow,
CustomerId = customer.Id,
CompanyId = appointment.CompanyId
});
}
}
}
// ── Staff email ───────────────────────────────────────────────────
// Send to whoever created the appointment so they get an out-of-app reminder.
if (!string.IsNullOrWhiteSpace(appointment.CreatedBy))
{
// Look up the user's display name from Identity if available.
var staffUser = await _context.Users
.FirstOrDefaultAsync(u => u.Email == appointment.CreatedBy);
var staffName = !string.IsNullOrWhiteSpace(staffUser?.FullName)
? staffUser.FullName
: appointment.CreatedBy;
// Include a customer line only when a customer is linked.
var customerLine = appointment.Customer != null
? $"<br/><strong>Customer:</strong> {WebUtility.HtmlEncode(GetCustomerDisplayName(appointment.Customer))}"
: string.Empty;
var staffValues = new Dictionary<string, string>
{
["companyName"] = companyName,
["staffName"] = staffName,
["appointmentTitle"] = appointment.Title,
["appointmentDate"] = appointmentDate,
["appointmentTime"] = appointmentTime,
["customerLine"] = customerLine,
["locationLine"] = locationLine
};
var staffDefaultSubject = $"[Reminder] {appointment.Title} — {appointment.ScheduledStartTime:MMMM d, yyyy 'at' h:mm tt}";
var (staffSubject, staffHtml) = await GetRenderedEmailAsync(
appointment.CompanyId, NotificationType.AppointmentReminderStaff, staffValues, staffDefaultSubject);
var staffPlainText = StripHtml(staffHtml);
var (staffOk, staffErr, staffLog) = await SendToEmailListAsync(
appointment.CreatedBy, staffName, staffSubject, staffPlainText, staffHtml,
replyToEmail: replyToEmail, replyToName: replyToName);
await WriteLog(new NotificationLog
{
Channel = NotificationChannel.Email,
NotificationType = NotificationType.AppointmentReminderStaff,
Status = staffOk ? NotificationStatus.Sent : NotificationStatus.Failed,
RecipientName = staffName,
Recipient = staffLog,
Subject = staffSubject,
Message = staffPlainText,
ErrorMessage = staffErr,
SentAt = DateTime.UtcNow,
CompanyId = appointment.CompanyId
});
}
}
catch (Exception ex)
{
_logger.LogError(ex, "NotifyAppointmentReminderAsync failed for appointment {AppointmentId}", appointment.Id);
}
}
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------
// Fallback default templates (used when company has no DB template) // Fallback default templates (used when company has no DB template)
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------
@@ -1217,6 +1367,14 @@ public class NotificationService : INotificationService
"Payment Reminder — Invoice {{invoiceNumber}} ({{daysOverdue}} days overdue)", "Payment Reminder — Invoice {{invoiceNumber}} ({{daysOverdue}} days overdue)",
"<p>Dear {{customerName}},</p><p>This is a friendly reminder that invoice <strong>{{invoiceNumber}}</strong> for <strong>{{invoiceTotal}}</strong> was due on <strong>{{dueDate}}</strong> and is now <strong>{{daysOverdue}} days overdue</strong>.</p><p>Outstanding balance: <strong>{{balanceDue}}</strong></p><p>Please arrange payment at your earliest convenience. If you have already sent payment, please disregard this notice.</p><p>Thank you for your business with {{companyName}}.</p>" "<p>Dear {{customerName}},</p><p>This is a friendly reminder that invoice <strong>{{invoiceNumber}}</strong> for <strong>{{invoiceTotal}}</strong> was due on <strong>{{dueDate}}</strong> and is now <strong>{{daysOverdue}} days overdue</strong>.</p><p>Outstanding balance: <strong>{{balanceDue}}</strong></p><p>Please arrange payment at your earliest convenience. If you have already sent payment, please disregard this notice.</p><p>Thank you for your business with {{companyName}}.</p>"
), ),
[(NotificationType.AppointmentReminder, NotificationChannel.Email)] = (
"Appointment Reminder — {{appointmentTitle}} on {{appointmentDate}}",
"<p>Dear {{customerName}},</p><p>This is a reminder that you have an upcoming appointment with <strong>{{companyName}}</strong>.</p><p><strong>Appointment:</strong> {{appointmentTitle}}<br/><strong>Date &amp; Time:</strong> {{appointmentDate}} at {{appointmentTime}}{{locationLine}}</p><p>If you have any questions or need to reschedule, please contact us at your earliest convenience.</p><p>Thank you for choosing {{companyName}}.</p>"
),
[(NotificationType.AppointmentReminderStaff, NotificationChannel.Email)] = (
"[Reminder] {{appointmentTitle}} — {{appointmentDate}}",
"<p>Hi {{staffName}},</p><p>This is a reminder that you have an upcoming appointment.</p><p><strong>Appointment:</strong> {{appointmentTitle}}<br/><strong>Date &amp; Time:</strong> {{appointmentDate}} at {{appointmentTime}}{{customerLine}}{{locationLine}}</p><p>&#8212; {{companyName}}</p>"
),
}; };
public static (string? Subject, string Body)? Get(NotificationType type, NotificationChannel channel) public static (string? Subject, string Body)? Get(NotificationType type, NotificationChannel channel)
@@ -162,7 +162,6 @@ public class StripeConnectService : IStripeConnectService
decimal invoiceTotal, decimal invoiceTotal,
decimal surchargeAmount, decimal surchargeAmount,
string currency, string currency,
string customerEmail,
string invoiceNumber, string invoiceNumber,
int invoiceId) int invoiceId)
{ {
@@ -175,7 +174,6 @@ public class StripeConnectService : IStripeConnectService
{ {
Amount = amountInCents, Amount = amountInCents,
Currency = currency.ToLower(), Currency = currency.ToLower(),
ReceiptEmail = customerEmail,
Description = $"Invoice {invoiceNumber}", Description = $"Invoice {invoiceNumber}",
Metadata = new Dictionary<string, string> Metadata = new Dictionary<string, string>
{ {
@@ -215,7 +213,6 @@ public class StripeConnectService : IStripeConnectService
decimal depositAmount, decimal depositAmount,
decimal surchargeAmount, decimal surchargeAmount,
string currency, string currency,
string customerEmail,
string quoteNumber, string quoteNumber,
int quoteId) int quoteId)
{ {
@@ -228,7 +225,6 @@ public class StripeConnectService : IStripeConnectService
{ {
Amount = amountInCents, Amount = amountInCents,
Currency = currency.ToLower(), Currency = currency.ToLower(),
ReceiptEmail = customerEmail,
Description = $"Deposit for quote {quoteNumber}", Description = $"Deposit for quote {quoteNumber}",
Metadata = new Dictionary<string, string> Metadata = new Dictionary<string, string>
{ {
@@ -0,0 +1,162 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using PowderCoating.Application.Interfaces;
using PowderCoating.Infrastructure.Data;
namespace PowderCoating.Web.BackgroundServices;
/// <summary>
/// Polls every 60 seconds for appointments whose reminder window has opened and dispatches
/// an email to the linked customer plus an in-app bell notification to company staff.
///
/// Deduplication strategy: after selecting candidates the service immediately stamps
/// <c>ReminderSentAt</c> on each appointment and saves before calling the notification
/// methods. This prevents a second loop iteration from re-sending if notifications are slow
/// or the application restarts mid-batch. A 24-hour lookback window caps the query so that
/// appointments that slipped through (e.g., server downtime) are silently skipped rather
/// than sending a stale reminder.
/// </summary>
public class AppointmentReminderBackgroundService : BackgroundService
{
private readonly IServiceScopeFactory _scopeFactory;
private readonly ILogger<AppointmentReminderBackgroundService> _logger;
private static readonly TimeSpan PollingInterval = TimeSpan.FromMinutes(1);
/// <summary>
/// Appointments whose scheduled start is more than this far in the past are ignored even
/// if their reminder was never sent (server was down, etc.). We do not want to blast a
/// customer with a "your appointment is in 30 minutes" email hours after it was due.
/// </summary>
private static readonly TimeSpan MaxLookback = TimeSpan.FromHours(24);
public AppointmentReminderBackgroundService(
IServiceScopeFactory scopeFactory,
ILogger<AppointmentReminderBackgroundService> logger)
{
_scopeFactory = scopeFactory;
_logger = logger;
}
/// <summary>
/// Long-running loop that wakes every <see cref="PollingInterval"/> (60 s) and calls
/// <see cref="RunAsync"/>. Uses <see cref="Task.Delay"/> with the cancellation token so
/// the service shuts down promptly when the application stops.
/// </summary>
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
_logger.LogInformation("AppointmentReminderBackgroundService started.");
while (!stoppingToken.IsCancellationRequested)
{
try
{
await Task.Delay(PollingInterval, stoppingToken);
}
catch (OperationCanceledException)
{
break;
}
if (stoppingToken.IsCancellationRequested) break;
await RunAsync(stoppingToken);
}
_logger.LogInformation("AppointmentReminderBackgroundService stopped.");
}
/// <summary>
/// One poll iteration: find all appointments whose reminder window has opened, stamp them,
/// then dispatch email + in-app notifications. A fresh DI scope is created per poll so that
/// the DbContext change tracker is clean each time.
/// </summary>
private async Task RunAsync(CancellationToken ct)
{
try
{
using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
var notificationService = scope.ServiceProvider.GetRequiredService<INotificationService>();
var inAppService = scope.ServiceProvider.GetRequiredService<IInAppNotificationService>();
// ScheduledStartTime is stored as server-local time (no UTC conversion on form submit),
// so compare against DateTime.Now rather than UtcNow to avoid a 4-hour EDT offset.
var now = DateTime.Now;
var lookback = now - MaxLookback;
// Find appointments where:
// - Reminder is enabled and has not been sent yet
// - The reminder window has opened: ScheduledStartTime - ReminderMinutesBefore <= now
// - The appointment hasn't been sitting unprocessed for more than MaxLookback
// - The appointment status is not terminal (not cancelled, completed, no-show, etc.)
// IgnoreQueryFilters bypasses the tenant filter — no HTTP context in a background service.
var candidates = await db.Appointments
.IgnoreQueryFilters()
.Include(a => a.Customer)
.Include(a => a.AppointmentStatus)
.Where(a =>
!a.IsDeleted &&
a.IsReminderEnabled &&
a.ReminderSentAt == null &&
a.ScheduledStartTime > lookback &&
EF.Functions.DateDiffMinute(now, a.ScheduledStartTime) <= a.ReminderMinutesBefore &&
!a.AppointmentStatus.IsTerminalStatus)
.ToListAsync(ct);
if (candidates.Count == 0) return;
_logger.LogInformation(
"AppointmentReminderBackgroundService: {Count} appointment reminder(s) to dispatch.",
candidates.Count);
// Stamp ReminderSentAt before sending — prevents a restart from re-sending.
var stampedAt = now;
foreach (var appt in candidates)
appt.ReminderSentAt = stampedAt;
await db.SaveChangesAsync(ct);
// Now send notifications. Failures here don't roll back the stamp because we'd
// rather skip one reminder than spam a customer on every restart.
foreach (var appt in candidates)
{
if (ct.IsCancellationRequested) break;
try
{
// Email to linked customer (no-ops internally if customer has opted out)
await notificationService.NotifyAppointmentReminderAsync(appt);
// In-app bell notification for company staff
var when = appt.IsAllDay
? appt.ScheduledStartTime.ToString("MMMM d, yyyy")
: appt.ScheduledStartTime.ToString("MMMM d, yyyy 'at' h:mm tt");
await inAppService.CreateAsync(
companyId: appt.CompanyId,
title: $"Appointment Reminder: {appt.Title}",
message: $"{appt.AppointmentNumber} is scheduled for {when}.",
notificationType: "AppointmentReminder",
link: $"/Appointments/Details/{appt.Id}",
customerId: appt.CustomerId);
_logger.LogInformation(
"Reminder dispatched for appointment {AppointmentNumber} (id {Id}, company {CompanyId}).",
appt.AppointmentNumber, appt.Id, appt.CompanyId);
}
catch (Exception ex)
{
_logger.LogError(ex,
"Failed to dispatch reminder for appointment {AppointmentId}.", appt.Id);
}
}
}
catch (Exception ex)
{
_logger.LogError(ex, "AppointmentReminderBackgroundService poll failed.");
}
}
}
@@ -381,9 +381,15 @@ public class AppointmentsController : Controller
return View(dto); return View(dto);
} }
// Map changes // Map changes — capture old start before overwrite so we can detect a reschedule.
var previousStart = appointment.ScheduledStartTime;
_mapper.Map(dto, appointment); _mapper.Map(dto, appointment);
// If the appointment was rescheduled, clear the reminder stamp so the background
// service will fire again at the new time.
if (appointment.ScheduledStartTime != previousStart)
appointment.ReminderSentAt = null;
// Update // Update
await _unitOfWork.Appointments.UpdateAsync(appointment); await _unitOfWork.Appointments.UpdateAsync(appointment);
await _unitOfWork.CompleteAsync(); await _unitOfWork.CompleteAsync();
@@ -725,6 +731,8 @@ public class AppointmentsController : Controller
var duration = appointment.ScheduledEndTime - appointment.ScheduledStartTime; var duration = appointment.ScheduledEndTime - appointment.ScheduledStartTime;
appointment.ScheduledStartTime = start; appointment.ScheduledStartTime = start;
appointment.ScheduledEndTime = end; appointment.ScheduledEndTime = end;
// Drag-drop always changes the time — reset so the reminder fires at the new time.
appointment.ReminderSentAt = null;
await _unitOfWork.Appointments.UpdateAsync(appointment); await _unitOfWork.Appointments.UpdateAsync(appointment);
await _unitOfWork.CompleteAsync(); await _unitOfWork.CompleteAsync();
@@ -1461,6 +1461,7 @@ public class InvoicesController : Controller
} }
var currentUser = await _userManager.GetUserAsync(User); var currentUser = await _userManager.GetUserAsync(User);
var totalCreditCreated = 0m; // populated inside transaction, used in success message
await _unitOfWork.ExecuteInTransactionAsync(async () => await _unitOfWork.ExecuteInTransactionAsync(async () =>
{ {
@@ -1480,6 +1481,75 @@ public class InvoicesController : Controller
await _unitOfWork.Payments.SoftDeleteAsync(payment.Id); await _unitOfWork.Payments.SoftDeleteAsync(payment.Id);
} }
// Re-release any deposits that were applied to this invoice so they can be
// auto-applied to the replacement invoice. Without this, AppliedToInvoiceId
// stays set and the deposit lookup (AppliedToInvoiceId == null) skips them.
var appliedDeposits = await _unitOfWork.Deposits.FindAsync(
d => d.AppliedToInvoiceId == invoice.Id && !d.IsDeleted);
var totalDepositReleased = 0m;
foreach (var deposit in appliedDeposits)
{
deposit.AppliedToInvoiceId = null;
deposit.AppliedDate = null;
deposit.UpdatedAt = DateTime.UtcNow;
await _unitOfWork.Deposits.UpdateAsync(deposit);
totalDepositReleased += deposit.Amount;
}
// Restore the CustomerDeposits 2300 liability that was cleared when the deposits
// were applied. Mirrors the DR at apply time; follows the same simplified reversal
// pattern as the rest of the void (regular payment GL entries are also left as-is).
if (totalDepositReleased > 0)
{
var custDepositsAcctId = await GetCustomerDepositsAccountIdAsync(invoice.CompanyId);
await _accountBalanceService.CreditAsync(custDepositsAcctId, totalDepositReleased);
}
// Convert non-deposit payments (cash, card, check, online) to customer credits so
// the money isn't lost when the invoice is voided. Each payment becomes a CRED-
// Deposit record linked to the same job; it will auto-apply when the replacement
// invoice is created, exactly like a normal deposit.
var nonDepositPayments = invoice.Payments
.Where(p => !p.IsDeleted && !(p.Reference ?? "").StartsWith("Deposit "))
.ToList();
if (nonDepositPayments.Any())
{
var credPrefix = $"CRED-{DateTime.UtcNow:yy}{DateTime.UtcNow.Month:D2}-";
var existingNums = (await _unitOfWork.Deposits.FindAsync(
d => d.CompanyId == invoice.CompanyId && d.ReceiptNumber.StartsWith(credPrefix),
ignoreQueryFilters: true))
.Select(d => d.ReceiptNumber).ToList();
var maxNum = 0;
foreach (var rn in existingNums)
{
var suffix = rn.Length >= credPrefix.Length + 4 ? rn[credPrefix.Length..] : "";
if (int.TryParse(suffix, out int parsed) && parsed > maxNum) maxNum = parsed;
}
var creditCustDepositsAcctId = await GetCustomerDepositsAccountIdAsync(invoice.CompanyId);
foreach (var payment in nonDepositPayments)
{
maxNum++;
await _unitOfWork.Deposits.AddAsync(new Core.Entities.Deposit
{
CompanyId = invoice.CompanyId,
CustomerId = invoice.CustomerId,
JobId = invoice.JobId,
Amount = payment.Amount,
PaymentMethod = payment.PaymentMethod,
ReceivedDate = payment.PaymentDate,
Reference = payment.Reference,
Notes = $"Credit from voided invoice {invoice.InvoiceNumber}" +
(string.IsNullOrWhiteSpace(payment.Notes) ? "." : $". Original: {payment.Notes}"),
ReceiptNumber = $"{credPrefix}{maxNum:D4}",
CreatedAt = DateTime.UtcNow
});
totalCreditCreated += payment.Amount;
}
// CR CustomerDeposits to create the liability matching the cash already in Checking
await _accountBalanceService.CreditAsync(creditCustDepositsAcctId, totalCreditCreated);
}
// Void any gift certificates that were generated from this invoice. // Void any gift certificates that were generated from this invoice.
// Capture each GC's remaining balance BEFORE voiding so the GL entries below can use it. // Capture each GC's remaining balance BEFORE voiding so the GL entries below can use it.
var gcLiabilityAcctId = await GetGcLiabilityAccountIdAsync(invoice.CompanyId); var gcLiabilityAcctId = await GetGcLiabilityAccountIdAsync(invoice.CompanyId);
@@ -1531,7 +1601,10 @@ public class InvoicesController : Controller
}); // end ExecuteInTransactionAsync }); // end ExecuteInTransactionAsync
TempData["Success"] = $"Invoice {invoice.InvoiceNumber} has been voided."; var creditMsg = totalCreditCreated > 0
? $" {totalCreditCreated:C} converted to customer credit and will auto-apply to the next invoice."
: "";
TempData["Success"] = $"Invoice {invoice.InvoiceNumber} has been voided.{creditMsg}";
return RedirectToAction(nameof(Details), new { id }); return RedirectToAction(nameof(Details), new { id });
} }
catch (Exception ex) catch (Exception ex)
@@ -477,6 +477,7 @@ public class JobsController : Controller
transferEfficiency = c.TransferEfficiency, transferEfficiency = c.TransferEfficiency,
powderCostPerLb = c.PowderCostPerLb, powderCostPerLb = c.PowderCostPerLb,
powderToOrder = c.PowderToOrder, powderToOrder = c.PowderToOrder,
noExtraLayerCharge = c.NoExtraLayerCharge,
notes = c.Notes notes = c.Notes
}), }),
prepServices = ji.PrepServices.Select(ps => new { prepServices = ji.PrepServices.Select(ps => new {
@@ -1078,6 +1079,8 @@ public class JobsController : Controller
QuoteId = dto.QuoteId, QuoteId = dto.QuoteId,
AssignedUserId = dto.AssignedUserId, AssignedUserId = dto.AssignedUserId,
OvenCostId = dto.OvenCostId, OvenCostId = dto.OvenCostId,
OvenBatches = dto.OvenBatches > 0 ? dto.OvenBatches : 1,
OvenCycleMinutes = dto.OvenCycleMinutes,
Description = dto.Description, Description = dto.Description,
JobPriorityId = dto.JobPriorityId, JobPriorityId = dto.JobPriorityId,
JobStatusId = pendingStatus?.Id ?? 1, JobStatusId = pendingStatus?.Id ?? 1,
@@ -1149,7 +1152,7 @@ public class JobsController : Controller
var totals = await _pricingService.CalculateQuoteTotalsAsync( var totals = await _pricingService.CalculateQuoteTotalsAsync(
dto.JobItems, companyId, dto.CustomerId, dto.JobItems, companyId, dto.CustomerId,
await GetEffectiveTaxPercentAsync(dto.CustomerId, createCosts?.TaxPercent ?? 0m), await GetEffectiveTaxPercentAsync(dto.CustomerId, createCosts?.TaxPercent ?? 0m),
dto.DiscountType, dto.DiscountValue, dto.IsRushJob, createOvenRate, job.OvenBatches, job.OvenCycleMinutes); dto.DiscountType, dto.DiscountValue, dto.IsRushJob, createOvenRate, dto.OvenBatches > 0 ? dto.OvenBatches : 1, dto.OvenCycleMinutes);
job.FinalPrice = totals.Total; job.FinalPrice = totals.Total;
job.OvenBatchCost = totals.OvenBatchCost; job.OvenBatchCost = totals.OvenBatchCost;
@@ -1217,6 +1220,9 @@ public class JobsController : Controller
CustomerId = job.CustomerId, CustomerId = job.CustomerId,
QuoteId = job.QuoteId, QuoteId = job.QuoteId,
AssignedUserId = job.AssignedUserId, AssignedUserId = job.AssignedUserId,
OvenCostId = job.OvenCostId,
OvenBatches = job.OvenBatches > 0 ? job.OvenBatches : 1,
OvenCycleMinutes = job.OvenCycleMinutes,
Description = job.Description, Description = job.Description,
JobStatusId = job.JobStatusId, JobStatusId = job.JobStatusId,
JobPriorityId = job.JobPriorityId, JobPriorityId = job.JobPriorityId,
@@ -1261,6 +1267,7 @@ public class JobsController : Controller
TransferEfficiency = c.TransferEfficiency, TransferEfficiency = c.TransferEfficiency,
PowderCostPerLb = c.PowderCostPerLb, PowderCostPerLb = c.PowderCostPerLb,
PowderToOrder = c.PowderToOrder, PowderToOrder = c.PowderToOrder,
NoExtraLayerCharge = c.NoExtraLayerCharge,
Notes = c.Notes Notes = c.Notes
}).ToList(), }).ToList(),
PrepServices = ji.PrepServices.Select(ps => new CreateQuoteItemPrepServiceDto PrepServices = ji.PrepServices.Select(ps => new CreateQuoteItemPrepServiceDto
@@ -1391,6 +1398,9 @@ public class JobsController : Controller
job.CustomerId = dto.CustomerId; job.CustomerId = dto.CustomerId;
job.QuoteId = dto.QuoteId; job.QuoteId = dto.QuoteId;
job.Description = dto.Description; job.Description = dto.Description;
job.OvenCostId = dto.OvenCostId;
job.OvenBatches = dto.OvenBatches > 0 ? dto.OvenBatches : 1;
job.OvenCycleMinutes = dto.OvenCycleMinutes;
await RecordStatusChangeAsync(job, dto.JobStatusId); await RecordStatusChangeAsync(job, dto.JobStatusId);
job.JobStatusId = dto.JobStatusId; job.JobStatusId = dto.JobStatusId;
job.JobPriorityId = dto.JobPriorityId; job.JobPriorityId = dto.JobPriorityId;
@@ -1617,7 +1627,7 @@ public class JobsController : Controller
var totals = await _pricingService.CalculateQuoteTotalsAsync( var totals = await _pricingService.CalculateQuoteTotalsAsync(
dto.JobItems, companyId, dto.CustomerId, dto.JobItems, companyId, dto.CustomerId,
await GetEffectiveTaxPercentAsync(dto.CustomerId, editCosts?.TaxPercent ?? 0m), await GetEffectiveTaxPercentAsync(dto.CustomerId, editCosts?.TaxPercent ?? 0m),
dto.DiscountType, dto.DiscountValue, dto.IsRushJob, editOvenRate, job.OvenBatches, job.OvenCycleMinutes); dto.DiscountType, dto.DiscountValue, dto.IsRushJob, editOvenRate, dto.OvenBatches > 0 ? dto.OvenBatches : 1, dto.OvenCycleMinutes);
job.FinalPrice = totals.Total; job.FinalPrice = totals.Total;
job.OvenBatchCost = totals.OvenBatchCost; job.OvenBatchCost = totals.OvenBatchCost;
job.ShopSuppliesAmount = totals.ShopSuppliesAmount; job.ShopSuppliesAmount = totals.ShopSuppliesAmount;
@@ -1818,6 +1828,7 @@ public class JobsController : Controller
ViewBag.ComplexityModeratePercent = costs?.ComplexityModeratePercent ?? 5m; ViewBag.ComplexityModeratePercent = costs?.ComplexityModeratePercent ?? 5m;
ViewBag.ComplexityComplexPercent = costs?.ComplexityComplexPercent ?? 15m; ViewBag.ComplexityComplexPercent = costs?.ComplexityComplexPercent ?? 15m;
ViewBag.ComplexityExtremePercent = costs?.ComplexityExtremePercent ?? 25m; ViewBag.ComplexityExtremePercent = costs?.ComplexityExtremePercent ?? 25m;
ViewBag.DefaultOvenCycleMinutes = costs?.DefaultOvenCycleMinutes ?? 45;
var useMetric = await _tenantContext.UseMetricSystemAsync(); var useMetric = await _tenantContext.UseMetricSystemAsync();
ViewBag.UseMetric = useMetric; ViewBag.UseMetric = useMetric;
ViewBag.AreaUnit = _measurementService.GetAreaUnitLabel(useMetric); ViewBag.AreaUnit = _measurementService.GetAreaUnitLabel(useMetric);
@@ -2936,6 +2947,7 @@ public class JobsController : Controller
TransferEfficiency = c.TransferEfficiency, TransferEfficiency = c.TransferEfficiency,
PowderCostPerLb = c.PowderCostPerLb, PowderCostPerLb = c.PowderCostPerLb,
PowderToOrder = c.PowderToOrder, PowderToOrder = c.PowderToOrder,
NoExtraLayerCharge = c.NoExtraLayerCharge,
Notes = c.Notes Notes = c.Notes
}).ToList(), }).ToList(),
PrepServices = ji.PrepServices.Select(ps => new CreateQuoteItemPrepServiceDto PrepServices = ji.PrepServices.Select(ps => new CreateQuoteItemPrepServiceDto
@@ -3116,7 +3128,8 @@ public class JobsController : Controller
InventoryItemId = c.InventoryItemId, InventoryItemId = c.InventoryItemId,
CoverageSqFtPerLb = c.CoverageSqFtPerLb, CoverageSqFtPerLb = c.CoverageSqFtPerLb,
TransferEfficiency = c.TransferEfficiency, TransferEfficiency = c.TransferEfficiency,
PowderCostPerLb = c.PowderCostPerLb PowderCostPerLb = c.PowderCostPerLb,
NoExtraLayerCharge = c.NoExtraLayerCharge
}).ToList() }).ToList()
}).ToList(); }).ToList();
@@ -75,7 +75,6 @@ public class PaymentController : Controller
CustomerName = customer != null CustomerName = customer != null
? $"{customer.ContactFirstName} {customer.ContactLastName}".Trim() ? $"{customer.ContactFirstName} {customer.ContactLastName}".Trim()
: "Valued Customer", : "Valued Customer",
CustomerEmail = customer?.Email ?? string.Empty,
CompanyName = company!.CompanyName, CompanyName = company!.CompanyName,
BalanceDue = invoice.BalanceDue, BalanceDue = invoice.BalanceDue,
InvoiceTotal = invoice.Total, InvoiceTotal = invoice.Total,
@@ -127,8 +126,6 @@ public class PaymentController : Controller
return BadRequest(new { error = "Invalid payment amount." }); return BadRequest(new { error = "Invalid payment amount." });
var surcharge = CalculateSurcharge(request.Amount, company!); var surcharge = CalculateSurcharge(request.Amount, company!);
var customer = await _context.Customers.AsNoTracking()
.FirstOrDefaultAsync(c => c.Id == invoice.CustomerId);
var (success, clientSecret, paymentIntentId, stripeError) = var (success, clientSecret, paymentIntentId, stripeError) =
await _stripeConnect.CreatePaymentIntentAsync( await _stripeConnect.CreatePaymentIntentAsync(
@@ -136,7 +133,6 @@ public class PaymentController : Controller
invoiceTotal: request.Amount, invoiceTotal: request.Amount,
surchargeAmount: surcharge, surchargeAmount: surcharge,
currency: "usd", currency: "usd",
customerEmail: customer?.Email ?? string.Empty,
invoiceNumber: invoice.InvoiceNumber, invoiceNumber: invoice.InvoiceNumber,
invoiceId: invoice.Id); invoiceId: invoice.Id);
@@ -261,7 +257,6 @@ public class PaymentController : Controller
CustomerName = customer != null CustomerName = customer != null
? $"{customer.ContactFirstName} {customer.ContactLastName}".Trim() ? $"{customer.ContactFirstName} {customer.ContactLastName}".Trim()
: quote.ProspectContactName ?? "Valued Customer", : quote.ProspectContactName ?? "Valued Customer",
CustomerEmail = customer?.Email ?? quote.ProspectEmail ?? string.Empty,
CompanyName = company!.CompanyName, CompanyName = company!.CompanyName,
DepositAmount = depositAmount, DepositAmount = depositAmount,
QuoteTotal = quote.Total, QuoteTotal = quote.Total,
@@ -296,7 +291,6 @@ public class PaymentController : Controller
var depositAmount = Math.Round(quote!.Total * (quote.DepositPercent / 100m), 2); var depositAmount = Math.Round(quote!.Total * (quote.DepositPercent / 100m), 2);
var surcharge = CalculateSurcharge(depositAmount, company!); var surcharge = CalculateSurcharge(depositAmount, company!);
var customerEmail = quote.Customer?.Email ?? quote.ProspectEmail ?? string.Empty;
var (success, clientSecret, paymentIntentId, stripeError) = var (success, clientSecret, paymentIntentId, stripeError) =
await _stripeConnect.CreateDepositPaymentIntentAsync( await _stripeConnect.CreateDepositPaymentIntentAsync(
@@ -304,7 +298,6 @@ public class PaymentController : Controller
depositAmount: depositAmount, depositAmount: depositAmount,
surchargeAmount: surcharge, surchargeAmount: surcharge,
currency: "usd", currency: "usd",
customerEmail: customerEmail,
quoteNumber: quote.QuoteNumber, quoteNumber: quote.QuoteNumber,
quoteId: quote.Id); quoteId: quote.Id);
@@ -942,7 +935,6 @@ public class PaymentPageViewModel
public int InvoiceId { get; set; } public int InvoiceId { get; set; }
public string Token { get; set; } = string.Empty; public string Token { get; set; } = string.Empty;
public string CustomerName { get; set; } = string.Empty; public string CustomerName { get; set; } = string.Empty;
public string CustomerEmail { get; set; } = string.Empty;
public string CompanyName { get; set; } = string.Empty; public string CompanyName { get; set; } = string.Empty;
public decimal BalanceDue { get; set; } public decimal BalanceDue { get; set; }
public decimal InvoiceTotal { get; set; } public decimal InvoiceTotal { get; set; }
@@ -963,7 +955,6 @@ public class DepositPaymentPageViewModel
public int QuoteId { get; set; } public int QuoteId { get; set; }
public string Token { get; set; } = string.Empty; public string Token { get; set; } = string.Empty;
public string CustomerName { get; set; } = string.Empty; public string CustomerName { get; set; } = string.Empty;
public string CustomerEmail { get; set; } = string.Empty;
public string CompanyName { get; set; } = string.Empty; public string CompanyName { get; set; } = string.Empty;
public decimal DepositAmount { get; set; } public decimal DepositAmount { get; set; }
public decimal QuoteTotal { get; set; } public decimal QuoteTotal { get; set; }
+6
View File
@@ -240,6 +240,7 @@ builder.Services.AddHostedService<AuditLogRetentionBackgroundService>();
builder.Services.AddHostedService<StripeWebhookRetentionBackgroundService>(); builder.Services.AddHostedService<StripeWebhookRetentionBackgroundService>();
builder.Services.AddHostedService<SetupWizardReminderBackgroundService>(); builder.Services.AddHostedService<SetupWizardReminderBackgroundService>();
builder.Services.AddHostedService<RecurringTransactionService>(); builder.Services.AddHostedService<RecurringTransactionService>();
builder.Services.AddHostedService<AppointmentReminderBackgroundService>();
builder.Services.AddScoped<ISubscriptionService, SubscriptionService>(); builder.Services.AddScoped<ISubscriptionService, SubscriptionService>();
builder.Services.AddScoped<IStripeService, StripeService>(); builder.Services.AddScoped<IStripeService, StripeService>();
builder.Services.AddScoped<IStripeConnectService, StripeConnectService>(); builder.Services.AddScoped<IStripeConnectService, StripeConnectService>();
@@ -652,6 +653,11 @@ app.Use(async (context, next) =>
context.Response.Headers.Append("Permissions-Policy", context.Response.Headers.Append("Permissions-Policy",
"geolocation=(), microphone=(), camera=()"); "geolocation=(), microphone=(), camera=()");
// Prevent browsers from caching authenticated pages — avoids stale data and
// browser-specific cache corruption bugs (e.g. Firefox caching a partial load).
if (context.User.Identity?.IsAuthenticated == true)
context.Response.Headers.Append("Cache-Control", "no-store");
await next(); await next();
}); });
@@ -185,12 +185,13 @@
<div class="mobile-card-view"> <div class="mobile-card-view">
@if (!Model.Items.Any()) @if (!Model.Items.Any())
{ {
var isMobileCustomerListFiltered = !string.IsNullOrEmpty(ViewBag.SearchTerm as string);
<div class="text-center py-5"> <div class="text-center py-5">
<i class="bi bi-inbox" style="font-size: 4rem; color: #d1d5db;"></i> <i class="bi bi-inbox" style="font-size: 4rem; color: #d1d5db;"></i>
<h5 class="mt-3 text-muted">No customers found</h5> <h5 class="mt-3 text-muted">No customers found</h5>
<p class="text-muted mb-4">@(isCustomerListFiltered ? "No customers match your search." : "Get started by adding your first customer.")</p> <p class="text-muted mb-4">@(isMobileCustomerListFiltered ? "No customers match your search." : "Get started by adding your first customer.")</p>
<a asp-action="Create" class="btn btn-primary"> <a asp-action="Create" class="btn btn-primary">
<i class="bi bi-plus-circle me-2"></i>@(isCustomerListFiltered ? "Add Customer" : "Add Your First Customer") <i class="bi bi-plus-circle me-2"></i>@(isMobileCustomerListFiltered ? "Add Customer" : "Add Your First Customer")
</a> </a>
</div> </div>
} }
@@ -28,6 +28,9 @@
var onlinePaymentsEnabled = ViewBag.OnlinePaymentsEnabled == true; var onlinePaymentsEnabled = ViewBag.OnlinePaymentsEnabled == true;
var showOnlinePaymentCard = !isDraft && !isVoided && Model.BalanceDue > 0 && onlinePaymentsEnabled; var showOnlinePaymentCard = !isDraft && !isVoided && Model.BalanceDue > 0 && onlinePaymentsEnabled;
var guidedActivationCallout = ViewBag.GuidedActivationCallout as PowderCoating.Web.ViewModels.GuidedActivation.GuidedActivationCalloutViewModel; var guidedActivationCallout = ViewBag.GuidedActivationCallout as PowderCoating.Web.ViewModels.GuidedActivation.GuidedActivationCalloutViewModel;
var nonDepositPaymentTotal = Model.Payments
.Where(p => !(p.Reference ?? "").StartsWith("Deposit "))
.Sum(p => p.Amount);
} }
<div class="row justify-content-center"> <div class="row justify-content-center">
@@ -406,7 +409,7 @@
@if (!isVoided) @if (!isVoided)
{ {
<button type="button" class="btn btn-sm btn-outline-secondary me-1" title="Edit payment" <button type="button" class="btn btn-sm btn-outline-secondary me-1" title="Edit payment"
onclick="openEditPaymentModal(@p.Id, @Model.Id, '@p.PaymentDate.ToString("yyyy-MM-dd")', @((int)p.PaymentMethod), '@(p.Reference ?? "")', '@(p.Notes ?? "")', @(p.DepositAccountId?.ToString() ?? "null"))"> onclick="openEditPaymentModal(@p.Id, @Model.Id, '@p.PaymentDate.ToString("yyyy-MM-dd")', @((int)p.PaymentMethod), @Json.Serialize(p.Reference ?? ""), @Json.Serialize(p.Notes ?? ""), @(p.DepositAccountId?.ToString() ?? "null"))">
<i class="bi bi-pencil"></i> <i class="bi bi-pencil"></i>
</button> </button>
<form asp-action="DeletePayment" asp-route-invoiceId="@Model.Id" asp-route-paymentId="@p.Id" <form asp-action="DeletePayment" asp-route-invoiceId="@Model.Id" asp-route-paymentId="@p.Id"
@@ -694,7 +697,9 @@
@if (!isVoided && Model.Status != InvoiceStatus.Paid) @if (!isVoided && Model.Status != InvoiceStatus.Paid)
{ {
<form asp-action="Void" asp-route-id="@Model.Id" method="post" <form asp-action="Void" asp-route-id="@Model.Id" method="post"
onsubmit="return confirm('Void this invoice? This will reverse the remaining balance on the customer account.')"> onsubmit="return confirm('@(nonDepositPaymentTotal > 0
? $"Void this invoice? {nonDepositPaymentTotal.ToString("C")} in payments will be converted to a customer credit and auto-applied to the next invoice."
: "Void this invoice? This will reverse the remaining balance on the customer account.")')">
@Html.AntiForgeryToken() @Html.AntiForgeryToken()
<button type="submit" class="btn btn-outline-warning w-100"> <button type="submit" class="btn btn-outline-warning w-100">
<i class="bi bi-x-circle me-2"></i>Void Invoice <i class="bi bi-x-circle me-2"></i>Void Invoice
@@ -869,9 +874,13 @@
</div> </div>
</div> </div>
@if (showSendModal) }
{
<!-- Send Channel Choice Modal (shown when customer has both email + SMS) --> @if (showSendModal)
{
<!-- Send Channel Choice Modal (shown when customer has both email + SMS available).
Lives outside the isDraft block so it also renders for Sent/Overdue invoices
where the customer's email was added after an SMS-only initial send. -->
<div class="modal fade" id="sendChannelModal" tabindex="-1" aria-labelledby="sendChannelModalLabel" aria-hidden="true"> <div class="modal fade" id="sendChannelModal" tabindex="-1" aria-labelledby="sendChannelModalLabel" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered"> <div class="modal-dialog modal-dialog-centered">
<div class="modal-content"> <div class="modal-content">
@@ -886,11 +895,11 @@
<div class="d-grid gap-2"> <div class="d-grid gap-2">
<button type="button" class="btn btn-outline-primary text-start" onclick="submitSendInvoice(true, false)" data-bs-dismiss="modal"> <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 <i class="bi bi-envelope me-2"></i>Email only
<small class="d-block text-muted ms-4">PDF attached · @Model.CustomerEmail</small> <small class="d-block text-muted ms-4">PDF attached &middot; @Model.CustomerEmail</small>
</button> </button>
<button type="button" class="btn btn-outline-primary text-start" onclick="submitSendInvoice(false, true)" data-bs-dismiss="modal"> <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 <i class="bi bi-phone me-2"></i>SMS only
<small class="d-block text-muted ms-4">View link · @smsPhone</small> <small class="d-block text-muted ms-4">View link &middot; @smsPhone</small>
</button> </button>
<button type="button" class="btn btn-primary text-start" onclick="submitSendInvoice(true, true)" data-bs-dismiss="modal"> <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 <i class="bi bi-send me-2"></i>Both Email &amp; SMS
@@ -904,7 +913,6 @@
</div> </div>
</div> </div>
</div> </div>
}
} }
@if (canPay) @if (canPay)
+46 -2
View File
@@ -221,6 +221,49 @@
</div> </div>
</div> </div>
<!-- Oven & Batch Settings -->
<div class="card mb-4">
<div class="card-header">
<div class="d-flex align-items-center gap-2">
<h5 class="mb-0"><i class="bi bi-thermometer-half me-2"></i>Oven &amp; Batch Settings</h5>
<a tabindex="0" class="help-icon" role="button"
data-bs-toggle="popover" data-bs-placement="right" data-bs-trigger="focus"
data-bs-title="Oven &amp; Batch Pricing"
data-bs-content="The oven cost is charged once per batch at the job level, not per item. Estimate how many oven loads the full job will fill &amp;mdash; for example, if you have 20 small parts and your oven fits 10, that&amp;#39;s 2 batches. Cycle time is how long each batch runs. The cost is calculated from your oven&amp;#39;s hourly rate in Settings.">
<i class="bi bi-question-circle"></i>
</a>
</div>
</div>
<div class="card-body">
<p class="text-muted small mb-3">Estimate how many oven loads the complete job will require. The oven cycle cost is added once at the job level, not per item.</p>
<div class="row g-3 align-items-end">
@if (ViewBag.OvenCosts != null && ((List<Microsoft.AspNetCore.Mvc.Rendering.SelectListItem>)ViewBag.OvenCosts).Count > 1)
{
<div class="col-md-4">
<label asp-for="OvenCostId" class="form-label fw-semibold"><i class="bi bi-thermometer-half me-1"></i>Oven</label>
<select asp-for="OvenCostId" asp-items="@((List<Microsoft.AspNetCore.Mvc.Rendering.SelectListItem>)ViewBag.OvenCosts)"
class="form-select" onchange="scheduleAutoPricing()"></select>
</div>
}
<div class="col-sm-3 col-md-2">
<label asp-for="OvenBatches" class="form-label fw-semibold">Batches</label>
<input asp-for="OvenBatches" class="form-control" type="number" min="1"
value="@(Model.OvenBatches > 0 ? Model.OvenBatches : 1)"
id="OvenBatches" onchange="scheduleAutoPricing()" />
</div>
<div class="col-sm-4 col-md-3">
<label asp-for="OvenCycleMinutes" class="form-label fw-semibold">
Cycle Time per Batch (min)
<small class="text-muted fw-normal">default: @(ViewBag.DefaultOvenCycleMinutes ?? 45)</small>
</label>
<input asp-for="OvenCycleMinutes" class="form-control" type="number" min="1"
id="OvenCycleMinutes" placeholder="@(ViewBag.DefaultOvenCycleMinutes ?? 45)"
onchange="scheduleAutoPricing()" />
</div>
</div>
</div>
</div>
<!-- Pricing Options (Rush / Discount) --> <!-- Pricing Options (Rush / Discount) -->
<div class="card mb-4"> <div class="card mb-4">
<div class="card-header"> <div class="card-header">
@@ -370,7 +413,8 @@
transferEfficiency = c.TransferEfficiency, transferEfficiency = c.TransferEfficiency,
powderCostPerLb = c.PowderCostPerLb, powderCostPerLb = c.PowderCostPerLb,
powderToOrder = c.PowderToOrder, powderToOrder = c.PowderToOrder,
notes = c.Notes notes = c.Notes,
noExtraLayerCharge = c.NoExtraLayerCharge
}), }),
prepServices = item.PrepServices.Select(ps => new { prepServices = item.PrepServices.Select(ps => new {
prepServiceId = ps.PrepServiceId, prepServiceId = ps.PrepServiceId,
@@ -387,7 +431,7 @@
"discountType": @Json.Serialize(Model.DiscountType), "discountType": @Json.Serialize(Model.DiscountType),
"discountValue": @Model.DiscountValue, "discountValue": @Model.DiscountValue,
"isRushJob": @Json.Serialize(Model.IsRushJob), "isRushJob": @Json.Serialize(Model.IsRushJob),
"ovenCostId": null, "ovenCostId": @Json.Serialize(Model.OvenCostId),
"areaUnit": @Json.Serialize((string?)ViewBag.AreaUnit), "areaUnit": @Json.Serialize((string?)ViewBag.AreaUnit),
"useMetric": @Json.Serialize((bool)(ViewBag.UseMetric ?? false)), "useMetric": @Json.Serialize((bool)(ViewBag.UseMetric ?? false)),
"pricingUrl": "@Url.Action("CalculatePricing", "Jobs")", "pricingUrl": "@Url.Action("CalculatePricing", "Jobs")",
@@ -653,7 +653,10 @@
<span class="mobile-card-value"> <span class="mobile-card-value">
@foreach (var coat in item.Coats.OrderBy(c => c.Sequence)) @foreach (var coat in item.Coats.OrderBy(c => c.Sequence))
{ {
<small class="d-block">@coat.CoatName@if (!string.IsNullOrEmpty(coat.ColorName)) { <text> &ndash; @coat.ColorName</text> }</small> <small class="d-block">
@coat.CoatName@if (!string.IsNullOrEmpty(coat.ColorName)) { <text> &ndash; @coat.ColorName</text> }
@if (!string.IsNullOrEmpty(coat.Notes)) { <text><br /><span class="fst-italic text-muted ms-2">@coat.Notes</span></text> }
</small>
} }
</span> </span>
</div> </div>
+46 -2
View File
@@ -190,6 +190,49 @@
</div> </div>
</div> </div>
<!-- Oven & Batch Settings -->
<div class="card mb-4">
<div class="card-header">
<div class="d-flex align-items-center gap-2">
<h5 class="mb-0"><i class="bi bi-thermometer-half me-2"></i>Oven &amp; Batch Settings</h5>
<a tabindex="0" class="help-icon" role="button"
data-bs-toggle="popover" data-bs-placement="right" data-bs-trigger="focus"
data-bs-title="Oven &amp; Batch Pricing"
data-bs-content="The oven cost is charged once per batch at the job level, not per item. Estimate how many oven loads the full job will fill &amp;mdash; for example, if you have 20 small parts and your oven fits 10, that&amp;#39;s 2 batches. Cycle time is how long each batch runs. The cost is calculated from your oven&amp;#39;s hourly rate in Settings.">
<i class="bi bi-question-circle"></i>
</a>
</div>
</div>
<div class="card-body">
<p class="text-muted small mb-3">Estimate how many oven loads the complete job will require. The oven cycle cost is added once at the job level, not per item.</p>
<div class="row g-3 align-items-end">
@if (ViewBag.OvenCosts != null && ((List<Microsoft.AspNetCore.Mvc.Rendering.SelectListItem>)ViewBag.OvenCosts).Count > 1)
{
<div class="col-md-4">
<label asp-for="OvenCostId" class="form-label fw-semibold"><i class="bi bi-thermometer-half me-1"></i>Oven</label>
<select asp-for="OvenCostId" asp-items="@((List<Microsoft.AspNetCore.Mvc.Rendering.SelectListItem>)ViewBag.OvenCosts)"
class="form-select" onchange="scheduleAutoPricing()"></select>
</div>
}
<div class="col-sm-3 col-md-2">
<label asp-for="OvenBatches" class="form-label fw-semibold">Batches</label>
<input asp-for="OvenBatches" class="form-control" type="number" min="1"
value="@(Model.OvenBatches > 0 ? Model.OvenBatches : 1)"
id="OvenBatches" onchange="scheduleAutoPricing()" />
</div>
<div class="col-sm-4 col-md-3">
<label asp-for="OvenCycleMinutes" class="form-label fw-semibold">
Cycle Time per Batch (min)
<small class="text-muted fw-normal">default: @(ViewBag.DefaultOvenCycleMinutes ?? 45)</small>
</label>
<input asp-for="OvenCycleMinutes" class="form-control" type="number" min="1"
id="OvenCycleMinutes" placeholder="@(ViewBag.DefaultOvenCycleMinutes ?? 45)"
onchange="scheduleAutoPricing()" />
</div>
</div>
</div>
</div>
<!-- Pricing Options (Rush / Discount) --> <!-- Pricing Options (Rush / Discount) -->
<div class="card mb-4"> <div class="card mb-4">
<div class="card-header"> <div class="card-header">
@@ -357,7 +400,8 @@
transferEfficiency = c.TransferEfficiency, transferEfficiency = c.TransferEfficiency,
powderCostPerLb = c.PowderCostPerLb, powderCostPerLb = c.PowderCostPerLb,
powderToOrder = c.PowderToOrder, powderToOrder = c.PowderToOrder,
notes = c.Notes notes = c.Notes,
noExtraLayerCharge = c.NoExtraLayerCharge
}), }),
prepServices = item.PrepServices.Select(ps => new { prepServices = item.PrepServices.Select(ps => new {
prepServiceId = ps.PrepServiceId, prepServiceId = ps.PrepServiceId,
@@ -374,7 +418,7 @@
"discountType": @Json.Serialize(Model.DiscountType), "discountType": @Json.Serialize(Model.DiscountType),
"discountValue": @Model.DiscountValue, "discountValue": @Model.DiscountValue,
"isRushJob": @Json.Serialize(Model.IsRushJob), "isRushJob": @Json.Serialize(Model.IsRushJob),
"ovenCostId": null, "ovenCostId": @Json.Serialize(Model.OvenCostId),
"areaUnit": @Json.Serialize((string?)ViewBag.AreaUnit), "areaUnit": @Json.Serialize((string?)ViewBag.AreaUnit),
"useMetric": @Json.Serialize((bool)(ViewBag.UseMetric ?? false)), "useMetric": @Json.Serialize((bool)(ViewBag.UseMetric ?? false)),
"pricingUrl": "@Url.Action("CalculatePricing", "Jobs")", "pricingUrl": "@Url.Action("CalculatePricing", "Jobs")",
@@ -153,7 +153,8 @@
transferEfficiency = c.TransferEfficiency, transferEfficiency = c.TransferEfficiency,
powderCostPerLb = c.PowderCostPerLb, powderCostPerLb = c.PowderCostPerLb,
powderToOrder = c.PowderToOrder, powderToOrder = c.PowderToOrder,
notes = c.Notes notes = c.Notes,
noExtraLayerCharge = c.NoExtraLayerCharge
}), }),
prepServices = item.PrepServices.Select(ps => new { prepServices = item.PrepServices.Select(ps => new {
prepServiceId = ps.PrepServiceId, prepServiceId = ps.PrepServiceId,
@@ -75,11 +75,11 @@
<div class="input-group mb-1"> <div class="input-group mb-1">
<span class="input-group-text">$</span> <span class="input-group-text">$</span>
<input type="number" id="paymentAmount" class="form-control" <input type="number" id="paymentAmount" class="form-control"
value="@Model.BalanceDue.ToString("F2")" value="@Model.TotalWithSurcharge.ToString("F2")"
min="0.01" max="@Model.BalanceDue.ToString("F2")" step="0.01" min="0.01" max="@Model.TotalWithSurcharge.ToString("F2")" step="0.01"
oninput="updateSurcharge()" /> oninput="updateSurcharge()" />
</div> </div>
<div class="form-text">Max: @Model.BalanceDue.ToString("C")</div> <div class="form-text">Max: @Model.TotalWithSurcharge.ToString("C")</div>
</div> </div>
</div> </div>
@@ -116,18 +116,17 @@
const STRIPE_PK = '@Model.StripePublishableKey'; const STRIPE_PK = '@Model.StripePublishableKey';
const ACCOUNT_ID = '@Model.StripeAccountId'; const ACCOUNT_ID = '@Model.StripeAccountId';
const TOKEN = '@Model.Token'; const TOKEN = '@Model.Token';
const MAX_AMOUNT = @Model.BalanceDue.ToString("F2"); const MAX_TOTAL = @Model.TotalWithSurcharge.ToString("F2", System.Globalization.CultureInfo.InvariantCulture);
const SURCHARGE_TYPE = '@Model.SurchargeType'; const SURCHARGE_TYPE = '@Model.SurchargeType';
const SURCHARGE_VALUE = @Model.SurchargeValue.ToString("F4"); const SURCHARGE_VALUE = @Model.SurchargeValue.ToString("F4", System.Globalization.CultureInfo.InvariantCulture);
const SUCCESS_URL = `/pay/${TOKEN}/success`; const SUCCESS_URL = `/pay/${TOKEN}/success`;
const stripe = Stripe(STRIPE_PK, { stripeAccount: ACCOUNT_ID }); const stripe = Stripe(STRIPE_PK, { stripeAccount: ACCOUNT_ID });
let elements, paymentElement; let elements, paymentElement;
async function initStripe() { async function initStripe() {
const amount = parseFloat(document.getElementById('paymentAmount').value) || MAX_AMOUNT; // Input value is the total the customer pays (including fee)
const surcharge = calcSurcharge(amount); const total = parseFloat(document.getElementById('paymentAmount').value) || MAX_TOTAL;
const total = Math.round((amount + surcharge) * 100) / 100;
elements = stripe.elements({ elements = stripe.elements({
mode: 'payment', mode: 'payment',
@@ -141,12 +140,15 @@
paymentElement.mount('#payment-element'); paymentElement.mount('#payment-element');
} }
function calcSurcharge(amount) { // Back-calculates the base invoice amount from a total that includes the fee.
// Server receives the base and adds surcharge on top — both paths arrive at the same
// PaymentIntent amount so Stripe.js never sees an amount mismatch.
function calcBaseFromTotal(total) {
if (SURCHARGE_TYPE === 'Percent') if (SURCHARGE_TYPE === 'Percent')
return Math.round(amount * (SURCHARGE_VALUE / 100) * 100) / 100; return Math.round(total / (1 + SURCHARGE_VALUE / 100) * 100) / 100;
if (SURCHARGE_TYPE === 'Flat') if (SURCHARGE_TYPE === 'Flat')
return SURCHARGE_VALUE; return Math.max(0, Math.round((total - SURCHARGE_VALUE) * 100) / 100);
return 0; return total;
} }
function formatCurrency(val) { function formatCurrency(val) {
@@ -154,9 +156,9 @@
} }
function updateSurcharge() { function updateSurcharge() {
const amount = Math.min(parseFloat(document.getElementById('paymentAmount').value) || 0, MAX_AMOUNT); const total = Math.min(parseFloat(document.getElementById('paymentAmount').value) || 0, MAX_TOTAL);
const surcharge = calcSurcharge(amount); const base = calcBaseFromTotal(total);
const total = amount + surcharge; const surcharge = Math.round((total - base) * 100) / 100;
const surchargeEl = document.getElementById('surchargeDisplay'); const surchargeEl = document.getElementById('surchargeDisplay');
const totalEl = document.getElementById('totalWithSurchargeDisplay'); const totalEl = document.getElementById('totalWithSurchargeDisplay');
@@ -164,7 +166,10 @@
if (surchargeEl) surchargeEl.textContent = formatCurrency(surcharge); if (surchargeEl) surchargeEl.textContent = formatCurrency(surcharge);
if (totalEl) totalEl.textContent = formatCurrency(total); if (totalEl) totalEl.textContent = formatCurrency(total);
if (btnAmtEl) btnAmtEl.textContent = surcharge > 0 ? formatCurrency(total) : formatCurrency(amount); if (btnAmtEl) btnAmtEl.textContent = formatCurrency(total);
// Keep Elements in sync so confirmPayment amount matches the PaymentIntent
if (elements) elements.update({ amount: Math.round(total * 100) });
} }
async function submitPayment() { async function submitPayment() {
@@ -172,13 +177,21 @@
const msgEl = document.getElementById('payment-message'); const msgEl = document.getElementById('payment-message');
const amountInput = document.getElementById('paymentAmount'); const amountInput = document.getElementById('paymentAmount');
const amount = parseFloat(amountInput.value); const total = parseFloat(amountInput.value);
if (!amount || amount <= 0 || amount > MAX_AMOUNT) { if (!total || total <= 0 || total > MAX_TOTAL) {
msgEl.textContent = 'Please enter a valid payment amount.'; msgEl.textContent = 'Please enter a valid payment amount.';
msgEl.style.display = ''; msgEl.style.display = '';
return; return;
} }
// Back-calculate base; server adds surcharge on top as usual
const base = calcBaseFromTotal(total);
if (base <= 0) {
msgEl.textContent = 'Payment amount must exceed the convenience fee.';
msgEl.style.display = '';
return;
}
btn.disabled = true; btn.disabled = true;
document.getElementById('submitText').style.display = 'none'; document.getElementById('submitText').style.display = 'none';
document.getElementById('submitSpinner').style.display = ''; document.getElementById('submitSpinner').style.display = '';
@@ -195,13 +208,13 @@
return; return;
} }
// Create PaymentIntent on server // Create PaymentIntent on server — send base amount (server adds surcharge)
let clientSecret; let clientSecret;
try { try {
const resp = await fetch(`/pay/${TOKEN}/intent`, { const resp = await fetch(`/pay/${TOKEN}/intent`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ amount }) body: JSON.stringify({ amount: base })
}); });
const data = await resp.json(); const data = await resp.json();
if (!resp.ok) { if (!resp.ok) {
@@ -479,7 +479,8 @@
transferEfficiency = c.TransferEfficiency, transferEfficiency = c.TransferEfficiency,
powderCostPerLb = c.PowderCostPerLb, powderCostPerLb = c.PowderCostPerLb,
powderToOrder = c.PowderToOrder, powderToOrder = c.PowderToOrder,
notes = c.Notes notes = c.Notes,
noExtraLayerCharge = c.NoExtraLayerCharge
}) })
}))) })))
</script> </script>
@@ -525,7 +525,8 @@
transferEfficiency = c.TransferEfficiency, transferEfficiency = c.TransferEfficiency,
powderCostPerLb = c.PowderCostPerLb, powderCostPerLb = c.PowderCostPerLb,
powderToOrder = c.PowderToOrder, powderToOrder = c.PowderToOrder,
notes = c.Notes notes = c.Notes,
noExtraLayerCharge = c.NoExtraLayerCharge
}) })
}))) })))
</script> </script>
@@ -82,6 +82,21 @@ document.addEventListener('DOMContentLoaded', () => {
const ownerForm = hfc?.closest('form'); const ownerForm = hfc?.closest('form');
if (ownerForm) { if (ownerForm) {
ownerForm.addEventListener('submit', writeHiddenFields, { capture: true }); ownerForm.addEventListener('submit', writeHiddenFields, { capture: true });
// Save scroll position before the form causes a full-page reload so we can
// restore it after the server redirects back to this page. Key is path-specific
// so navigating away and back doesn't restore a stale position.
const scrollKey = 'wizardScrollY:' + location.pathname;
ownerForm.addEventListener('submit', () => {
sessionStorage.setItem(scrollKey, String(Math.round(window.scrollY)));
}, { capture: true });
// Restore on load — fire after layout is painted so scrollTo lands correctly.
const savedY = sessionStorage.getItem(scrollKey);
if (savedY !== null) {
sessionStorage.removeItem(scrollKey);
requestAnimationFrame(() => window.scrollTo({ top: parseInt(savedY, 10), behavior: 'instant' }));
}
} }
// Close any open powder combobox or catalog lookup dropdown when clicking outside it // Close any open powder combobox or catalog lookup dropdown when clicking outside it
@@ -2731,8 +2746,9 @@ function buildCardHtml(item, i) {
const orderBadge = (!c.inventoryItemId && c.powderToOrder) const orderBadge = (!c.inventoryItemId && c.powderToOrder)
? ` <span class="badge bg-warning text-dark" style="font-size:.65em;vertical-align:middle;" title="Custom powder — must be purchased before coating"><i class="bi bi-cart me-1"></i>ORDER ${parseFloat(c.powderToOrder).toFixed(2)} lbs</span>` ? ` <span class="badge bg-warning text-dark" style="font-size:.65em;vertical-align:middle;" title="Custom powder — must be purchased before coating"><i class="bi bi-cart me-1"></i>ORDER ${parseFloat(c.powderToOrder).toFixed(2)} lbs</span>`
: ''; : '';
const coatNotes = c.notes ? `<span class="fst-italic ms-1 opacity-75">— ${escHtml(c.notes)}</span>` : '';
return `<div style="font-size:.8rem;" class="text-muted mt-1"> return `<div style="font-size:.8rem;" class="text-muted mt-1">
<i class="bi bi-layers me-1"></i><span class="fw-semibold">${escHtml(c.coatName || 'Coat')}</span>${color ? ` ${color}${code}` : ''}${orderBadge} <i class="bi bi-layers me-1"></i><span class="fw-semibold">${escHtml(c.coatName || 'Coat')}</span>${color ? ` ${color}${code}` : ''}${orderBadge}${coatNotes}
</div>`; </div>`;
}).join(''); }).join('');
@@ -3414,7 +3430,7 @@ function loadItemsFromTemplate(templateItems) {
coverageSqFtPerLb: c.coverageSqFtPerLb || 30, coverageSqFtPerLb: c.coverageSqFtPerLb || 30,
transferEfficiency: c.transferEfficiency || 65, transferEfficiency: c.transferEfficiency || 65,
powderCostPerLb: c.powderCostPerLb || null, powderCostPerLb: c.powderCostPerLb || null,
noExtraLayerCharge: false noExtraLayerCharge: !!c.noExtraLayerCharge
})), })),
prepServices: (ti.prepServices || []).map(p => ({ prepServices: (ti.prepServices || []).map(p => ({
prepServiceId: p.prepServiceId, prepServiceId: p.prepServiceId,