Compare commits

..

1 Commits

Author SHA1 Message Date
spouliot f671f7e62e Add WisePOS E in-person card payments (Stripe Terminal)
Server-driven Stripe Terminal integration for taking in-person card payments
against an invoice, running on the same Stripe Connect connected account used
for online payments. No native app or Terminal SDK — the WisePOS E is driven
from the web backend via Stripe's REST API.

- Domain: TerminalReader entity + status enum, PaymentMethod.CardReader,
  Company.StripeTerminalLocationId / TerminalSurchargeEnabled, DbSet + tenant
  filter + indexes, IUnitOfWork repo, migration AddTerminalReaders (additive).
- StripeConnectService: location/reader registration, list, delete, process
  payment on reader, status poll, cancel, and a test-mode simulated tap. All
  routed to the connected account like the existing online-payment methods.
- TerminalController: admin reader management + per-invoice ProcessPayment,
  PaymentStatus (poll), CancelPayment, SimulateTap (test mode only). Stores the
  PaymentIntent id on the invoice; the webhook remains the authoritative writer.
- PaymentController webhook: HandlePaymentSucceededAsync records source=terminal
  payments as CardReader (online path unchanged — no source key means no change);
  new terminal.reader.action_failed handler for declines/timeouts (notification
  only, no ledger mutation). Refund path reused unchanged.
- UI: Card Readers settings tab (register/list/deactivate + in-person surcharge
  toggle, default off with a compliance warning) and an invoice "Take Card
  Payment" modal with live status polling. External JS per project convention.
- Feature bundled with the existing online-payments entitlement (no new plan
  flag); additionally requires StripeConnectStatus == Active.
- Help: HelpKnowledgeBase + Invoices help article updated.
- Tests: TerminalController validation + surcharge-routing tests (241 pass).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 18:57:58 -04:00
35 changed files with 13416 additions and 312 deletions
@@ -37,7 +37,6 @@ public class PaymentDtos
public string? Reference { get; set; } public string? Reference { get; set; }
public string? Notes { get; set; } public string? Notes { get; set; }
public int? DepositAccountId { get; set; } public int? DepositAccountId { get; set; }
public bool SuppressNotification { get; set; }
} }
public class EditPaymentDto public class EditPaymentDto
@@ -0,0 +1,29 @@
namespace PowderCoating.Application.DTOs.Terminal
{
/// <summary>
/// Minimal postal address used to create a Stripe Terminal Location. Kept in the Application
/// layer so <c>IStripeConnectService</c> doesn't leak Stripe SDK types to controllers.
/// </summary>
public class TerminalAddressDto
{
public string Line1 { get; set; } = string.Empty;
public string City { get; set; } = string.Empty;
public string State { get; set; } = string.Empty;
public string PostalCode { get; set; } = string.Empty;
public string Country { get; set; } = "US";
}
/// <summary>
/// A Stripe Terminal reader as returned by the Stripe API, projected to a plain DTO for the
/// settings page and reconciliation. Stripe remains the source of truth for live network status.
/// </summary>
public class TerminalReaderDto
{
public string StripeReaderId { get; set; } = string.Empty;
public string Label { get; set; } = string.Empty;
public string DeviceType { get; set; } = string.Empty;
public string? SerialNumber { get; set; }
public string? NetworkStatus { get; set; } // "online" / "offline"
public DateTime? LastSeenAt { get; set; }
}
}
@@ -1,3 +1,5 @@
using PowderCoating.Application.DTOs.Terminal;
namespace PowderCoating.Application.Interfaces; namespace PowderCoating.Application.Interfaces;
public interface IStripeConnectService public interface IStripeConnectService
@@ -34,4 +36,75 @@ public interface IStripeConnectService
string currency, string currency,
string quoteNumber, string quoteNumber,
int quoteId); int quoteId);
// ----- Stripe Terminal (in-person card payments, WisePOS E) -----
// All methods route to the connected account via RequestOptions.StripeAccount, mirroring the
// online payment methods above. They return structured tuples instead of throwing.
/// <summary>
/// Creates the shop's single Stripe Terminal Location (one per company) from its address.
/// Readers must be attached to a Location. Returns the new Location id (tml_xxx).
/// </summary>
Task<(bool Success, string? LocationId, string? ErrorMessage)> CreateTerminalLocationAsync(
string connectedAccountId,
string displayName,
TerminalAddressDto address);
/// <summary>
/// Registers a physical (or simulated) reader to the shop's Location using the registration
/// code shown on the device. Returns the reader id (tmr_xxx), device type and serial number.
/// </summary>
Task<(bool Success, string? ReaderId, string? DeviceType, string? SerialNumber, string? ErrorMessage)> RegisterReaderAsync(
string connectedAccountId,
string locationId,
string registrationCode,
string label);
/// <summary>Lists the readers attached to the shop's Location (for status refresh/reconciliation).</summary>
Task<(bool Success, IReadOnlyList<TerminalReaderDto> Readers, string? ErrorMessage)> ListReadersAsync(
string connectedAccountId,
string locationId);
/// <summary>Unregisters (deletes) a reader from Stripe.</summary>
Task<(bool Success, string? ErrorMessage)> DeleteReaderAsync(
string connectedAccountId,
string readerId);
/// <summary>
/// Creates a card_present PaymentIntent for an invoice and pushes it to the physical reader,
/// which then prompts the customer to tap/insert/swipe. Metadata carries <c>source=terminal</c>
/// so the existing <c>payment_intent.succeeded</c> webhook records it as a card-reader payment.
/// Returns the PaymentIntent id so the caller can store it on the invoice for idempotency.
/// </summary>
Task<(bool Success, string? PaymentIntentId, string? ErrorMessage)> ProcessInvoicePaymentOnReaderAsync(
string connectedAccountId,
string readerId,
decimal amount,
decimal surchargeAmount,
string currency,
string invoiceNumber,
int invoiceId);
/// <summary>
/// Reads the reader's current action status for live UI feedback. The authoritative payment
/// record is still created by the webhook — this is only for showing progress to the clerk.
/// </summary>
Task<(bool Success, string? ActionStatus, string? ActionType, string? PaymentIntentId,
string? FailureCode, string? FailureMessage, string? NetworkStatus, string? ErrorMessage)> GetReaderStatusAsync(
string connectedAccountId,
string readerId);
/// <summary>Cancels the reader's in-progress action (clerk cancelled or wants to retry).</summary>
Task<(bool Success, string? ErrorMessage)> CancelReaderActionAsync(
string connectedAccountId,
string readerId);
/// <summary>
/// TEST MODE ONLY: simulates a card tap on a simulated reader so the payment can complete
/// without physical hardware. Uses the Stripe TestHelpers Terminal API. Callers must guard
/// this to test mode; it is a no-op against real readers.
/// </summary>
Task<(bool Success, string? ErrorMessage)> SimulatePresentPaymentMethodAsync(
string connectedAccountId,
string readerId);
} }
@@ -45,6 +45,11 @@ public class Company : BaseEntity
public decimal OnlinePaymentSurchargeValue { get; set; } = 0; // % or flat $ depending on type public decimal OnlinePaymentSurchargeValue { get; set; } = 0; // % or flat $ depending on type
public bool OnlineSurchargeAcknowledged { get; set; } = false; // shop accepted compliance disclaimer public bool OnlineSurchargeAcknowledged { get; set; } = false; // shop accepted compliance disclaimer
// Stripe Terminal — in-person card payments (WisePOS E). Runs on the same connected account
// as online payments; a single Terminal Location is created once per shop from its address.
public string? StripeTerminalLocationId { get; set; } // tml_xxx
public bool TerminalSurchargeEnabled { get; set; } = false; // default OFF — in-person surcharge rules vary by state
/// <summary>Internal notes about manual subscription changes (not shown to the company).</summary> /// <summary>Internal notes about manual subscription changes (not shown to the company).</summary>
public string? SubscriptionNotes { get; set; } public string? SubscriptionNotes { get; set; }
@@ -0,0 +1,41 @@
using PowderCoating.Core.Enums;
namespace PowderCoating.Core.Entities;
/// <summary>
/// A Stripe Terminal card reader (e.g. a BBPOS WisePOS E) registered to a company for in-person
/// card payments. The reader lives on the company's Stripe Connect connected account and is attached
/// to the company's single Terminal Location (<see cref="Company.StripeTerminalLocationId"/>).
/// <para>
/// We mirror only the identifiers and a friendly label locally; Stripe remains the source of truth
/// for live network status. <see cref="Status"/> is a local lifecycle flag (Active/Deactivated),
/// separate from Stripe's transient online/offline network state captured in
/// <see cref="LastKnownNetworkStatus"/>.
/// </para>
/// </summary>
public class TerminalReader : BaseEntity
{
/// <summary>Stripe reader id (tmr_xxx) returned when the reader is registered.</summary>
public string StripeReaderId { get; set; } = string.Empty;
/// <summary>Stripe Terminal Location id (tml_xxx) the reader is attached to — denormalized copy of <see cref="Company.StripeTerminalLocationId"/>.</summary>
public string StripeLocationId { get; set; } = string.Empty;
/// <summary>Shop-friendly name, e.g. "Front Counter".</summary>
public string Label { get; set; } = string.Empty;
/// <summary>Stripe device type, e.g. "bbpos_wisepos_e" or "simulated_wisepos_e" (test mode).</summary>
public string DeviceType { get; set; } = string.Empty;
/// <summary>Hardware serial number reported by Stripe, when available.</summary>
public string? SerialNumber { get; set; }
/// <summary>Local lifecycle state — whether the shop still uses this reader.</summary>
public TerminalReaderStatus Status { get; set; } = TerminalReaderStatus.Active;
/// <summary>Last network status snapshot from Stripe ("online"/"offline"), refreshed on poll. Advisory only.</summary>
public string? LastKnownNetworkStatus { get; set; }
/// <summary>When Stripe last saw the reader online, from the last status refresh.</summary>
public DateTime? LastSeenAt { get; set; }
}
+12 -1
View File
@@ -33,7 +33,18 @@ public enum PaymentMethod
CreditDebitCard = 2, CreditDebitCard = 2,
BankTransferACH = 3, BankTransferACH = 3,
DigitalPayment = 4, DigitalPayment = 4,
StoreCredit = 5 // Refund issued as store credit (creates a CreditMemo) StoreCredit = 5, // Refund issued as store credit (creates a CreditMemo)
CardReader = 6 // In-person card payment via a Stripe Terminal reader (WisePOS E)
}
/// <summary>
/// Local lifecycle state for a registered Stripe Terminal card reader. Distinct from Stripe's
/// network status (online/offline) — this tracks whether the shop still uses the reader.
/// </summary>
public enum TerminalReaderStatus
{
Active = 0,
Deactivated = 1 // Unregistered from Stripe and hidden from the shop's reader list
} }
public enum GiftCertificateStatus public enum GiftCertificateStatus
@@ -79,6 +79,7 @@ IRepository<ReworkRecord> ReworkRecords { get; }
IRepository<InvoiceItem> InvoiceItems { get; } IRepository<InvoiceItem> InvoiceItems { get; }
IRepository<Payment> Payments { get; } IRepository<Payment> Payments { get; }
IRepository<Deposit> Deposits { get; } IRepository<Deposit> Deposits { get; }
IRepository<TerminalReader> TerminalReaders { get; }
// Purchase Orders — typed repository for paged/filtered list and detail load // Purchase Orders — typed repository for paged/filtered list and detail load
IPurchaseOrderRepository PurchaseOrders { get; } IPurchaseOrderRepository PurchaseOrders { get; }
@@ -321,6 +321,8 @@ public class ApplicationDbContext : IdentityDbContext<ApplicationUser>, IDataPro
/// Auto-applied to the invoice when it is created (unapplied deposits are swept into Payment records). /// Auto-applied to the invoice when it is created (unapplied deposits are swept into Payment records).
/// </summary> /// </summary>
public DbSet<Deposit> Deposits { get; set; } public DbSet<Deposit> Deposits { get; set; }
/// <summary>Registered Stripe Terminal card readers (WisePOS E) for in-person payments; tenant-filtered with soft delete.</summary>
public DbSet<TerminalReader> TerminalReaders { get; set; }
// Purchase Orders // Purchase Orders
/// <summary>Purchase orders issued to vendors; tenant-filtered with soft delete.</summary> /// <summary>Purchase orders issued to vendors; tenant-filtered with soft delete.</summary>
@@ -656,6 +658,8 @@ modelBuilder.Entity<ReworkRecord>().HasQueryFilter(e =>
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId)); !e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
modelBuilder.Entity<Deposit>().HasQueryFilter(e => modelBuilder.Entity<Deposit>().HasQueryFilter(e =>
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId)); !e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
modelBuilder.Entity<TerminalReader>().HasQueryFilter(e =>
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
// Deposit → Invoice (nullable, no cascade) // Deposit → Invoice (nullable, no cascade)
modelBuilder.Entity<Deposit>() modelBuilder.Entity<Deposit>()
@@ -1912,6 +1916,15 @@ modelBuilder.Entity<Job>()
.HasIndex(p => new { p.CompanyId, p.PaymentDate }) .HasIndex(p => new { p.CompanyId, p.PaymentDate })
.HasDatabaseName("IX_Payments_CompanyId_PaymentDate"); .HasDatabaseName("IX_Payments_CompanyId_PaymentDate");
// Terminal readers — looked up by Stripe id (webhook/registration) and by company (settings list)
modelBuilder.Entity<TerminalReader>()
.HasIndex(r => r.StripeReaderId)
.IsUnique()
.HasDatabaseName("IX_TerminalReaders_StripeReaderId");
modelBuilder.Entity<TerminalReader>()
.HasIndex(r => r.CompanyId)
.HasDatabaseName("IX_TerminalReaders_CompanyId");
modelBuilder.Entity<NotificationLog>() modelBuilder.Entity<NotificationLog>()
.HasOne<Company>() .HasOne<Company>()
.WithMany() .WithMany()
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,124 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace PowderCoating.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class AddTerminalReaders : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "StripeTerminalLocationId",
table: "Companies",
type: "nvarchar(max)",
nullable: true);
migrationBuilder.AddColumn<bool>(
name: "TerminalSurchargeEnabled",
table: "Companies",
type: "bit",
nullable: false,
defaultValue: false);
migrationBuilder.CreateTable(
name: "TerminalReaders",
columns: table => new
{
Id = table.Column<int>(type: "int", nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
StripeReaderId = table.Column<string>(type: "nvarchar(450)", nullable: false),
StripeLocationId = table.Column<string>(type: "nvarchar(max)", nullable: false),
Label = table.Column<string>(type: "nvarchar(max)", nullable: false),
DeviceType = table.Column<string>(type: "nvarchar(max)", nullable: false),
SerialNumber = table.Column<string>(type: "nvarchar(max)", nullable: true),
Status = table.Column<int>(type: "int", nullable: false),
LastKnownNetworkStatus = table.Column<string>(type: "nvarchar(max)", nullable: true),
LastSeenAt = table.Column<DateTime>(type: "datetime2", nullable: true),
CompanyId = table.Column<int>(type: "int", nullable: false),
CreatedAt = table.Column<DateTime>(type: "datetime2", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
CreatedBy = table.Column<string>(type: "nvarchar(max)", nullable: true),
UpdatedBy = table.Column<string>(type: "nvarchar(max)", nullable: true),
IsDeleted = table.Column<bool>(type: "bit", nullable: false),
DeletedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
DeletedBy = table.Column<string>(type: "nvarchar(max)", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_TerminalReaders", x => x.Id);
});
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 1,
column: "CreatedAt",
value: new DateTime(2026, 6, 15, 22, 29, 10, 622, DateTimeKind.Utc).AddTicks(5996));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 2,
column: "CreatedAt",
value: new DateTime(2026, 6, 15, 22, 29, 10, 622, DateTimeKind.Utc).AddTicks(6003));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 3,
column: "CreatedAt",
value: new DateTime(2026, 6, 15, 22, 29, 10, 622, DateTimeKind.Utc).AddTicks(6004));
migrationBuilder.CreateIndex(
name: "IX_TerminalReaders_CompanyId",
table: "TerminalReaders",
column: "CompanyId");
migrationBuilder.CreateIndex(
name: "IX_TerminalReaders_StripeReaderId",
table: "TerminalReaders",
column: "StripeReaderId",
unique: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "TerminalReaders");
migrationBuilder.DropColumn(
name: "StripeTerminalLocationId",
table: "Companies");
migrationBuilder.DropColumn(
name: "TerminalSurchargeEnabled",
table: "Companies");
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 1,
column: "CreatedAt",
value: new DateTime(2026, 6, 14, 1, 21, 46, 131, DateTimeKind.Utc).AddTicks(4191));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 2,
column: "CreatedAt",
value: new DateTime(2026, 6, 14, 1, 21, 46, 131, DateTimeKind.Utc).AddTicks(4196));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 3,
column: "CreatedAt",
value: new DateTime(2026, 6, 14, 1, 21, 46, 131, DateTimeKind.Utc).AddTicks(4197));
}
}
}
@@ -1908,6 +1908,9 @@ namespace PowderCoating.Infrastructure.Migrations
b.Property<string>("StripeSubscriptionId") b.Property<string>("StripeSubscriptionId")
.HasColumnType("nvarchar(max)"); .HasColumnType("nvarchar(max)");
b.Property<string>("StripeTerminalLocationId")
.HasColumnType("nvarchar(max)");
b.Property<DateTime?>("SubscriptionEndDate") b.Property<DateTime?>("SubscriptionEndDate")
.HasColumnType("datetime2"); .HasColumnType("datetime2");
@@ -1923,6 +1926,9 @@ namespace PowderCoating.Infrastructure.Migrations
b.Property<int>("SubscriptionStatus") b.Property<int>("SubscriptionStatus")
.HasColumnType("int"); .HasColumnType("int");
b.Property<bool>("TerminalSurchargeEnabled")
.HasColumnType("bit");
b.Property<string>("TimeZone") b.Property<string>("TimeZone")
.HasColumnType("nvarchar(max)"); .HasColumnType("nvarchar(max)");
@@ -7210,7 +7216,7 @@ namespace PowderCoating.Infrastructure.Migrations
{ {
Id = 1, Id = 1,
CompanyId = 0, CompanyId = 0,
CreatedAt = new DateTime(2026, 6, 14, 1, 21, 46, 131, DateTimeKind.Utc).AddTicks(4191), CreatedAt = new DateTime(2026, 6, 15, 22, 29, 10, 622, DateTimeKind.Utc).AddTicks(5996),
Description = "Standard pricing for regular customers", Description = "Standard pricing for regular customers",
DiscountPercent = 0m, DiscountPercent = 0m,
IsActive = true, IsActive = true,
@@ -7221,7 +7227,7 @@ namespace PowderCoating.Infrastructure.Migrations
{ {
Id = 2, Id = 2,
CompanyId = 0, CompanyId = 0,
CreatedAt = new DateTime(2026, 6, 14, 1, 21, 46, 131, DateTimeKind.Utc).AddTicks(4196), CreatedAt = new DateTime(2026, 6, 15, 22, 29, 10, 622, DateTimeKind.Utc).AddTicks(6003),
Description = "5% discount for preferred customers", Description = "5% discount for preferred customers",
DiscountPercent = 5m, DiscountPercent = 5m,
IsActive = true, IsActive = true,
@@ -7232,7 +7238,7 @@ namespace PowderCoating.Infrastructure.Migrations
{ {
Id = 3, Id = 3,
CompanyId = 0, CompanyId = 0,
CreatedAt = new DateTime(2026, 6, 14, 1, 21, 46, 131, DateTimeKind.Utc).AddTicks(4197), CreatedAt = new DateTime(2026, 6, 15, 22, 29, 10, 622, DateTimeKind.Utc).AddTicks(6004),
Description = "10% discount for premium customers", Description = "10% discount for premium customers",
DiscountPercent = 10m, DiscountPercent = 10m,
IsActive = true, IsActive = true,
@@ -8738,6 +8744,78 @@ namespace PowderCoating.Infrastructure.Migrations
b.ToTable("TaxRates"); b.ToTable("TaxRates");
}); });
modelBuilder.Entity("PowderCoating.Core.Entities.TerminalReader", 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>("DeviceType")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<bool>("IsDeleted")
.HasColumnType("bit");
b.Property<string>("Label")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<string>("LastKnownNetworkStatus")
.HasColumnType("nvarchar(max)");
b.Property<DateTime?>("LastSeenAt")
.HasColumnType("datetime2");
b.Property<string>("SerialNumber")
.HasColumnType("nvarchar(max)");
b.Property<int>("Status")
.HasColumnType("int");
b.Property<string>("StripeLocationId")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<string>("StripeReaderId")
.IsRequired()
.HasColumnType("nvarchar(450)");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("datetime2");
b.Property<string>("UpdatedBy")
.HasColumnType("nvarchar(max)");
b.HasKey("Id");
b.HasIndex("CompanyId")
.HasDatabaseName("IX_TerminalReaders_CompanyId");
b.HasIndex("StripeReaderId")
.IsUnique()
.HasDatabaseName("IX_TerminalReaders_StripeReaderId");
b.ToTable("TerminalReaders");
});
modelBuilder.Entity("PowderCoating.Core.Entities.TermsAcceptance", b => modelBuilder.Entity("PowderCoating.Core.Entities.TermsAcceptance", b =>
{ {
b.Property<int>("Id") b.Property<int>("Id")
@@ -150,6 +150,7 @@ public class UnitOfWork : IUnitOfWork
private IRepository<InvoiceItem>? _invoiceItems; private IRepository<InvoiceItem>? _invoiceItems;
private IRepository<Payment>? _payments; private IRepository<Payment>? _payments;
private IRepository<Deposit>? _deposits; private IRepository<Deposit>? _deposits;
private IRepository<TerminalReader>? _terminalReaders;
// Expense Tracking / Accounts Payable // Expense Tracking / Accounts Payable
private IRepository<Account>? _accounts; private IRepository<Account>? _accounts;
@@ -555,6 +556,10 @@ public class UnitOfWork : IUnitOfWork
public IRepository<Deposit> Deposits => public IRepository<Deposit> Deposits =>
_deposits ??= new Repository<Deposit>(_context); _deposits ??= new Repository<Deposit>(_context);
/// <summary>Repository for <see cref="TerminalReader"/> registered Stripe Terminal card readers.</summary>
public IRepository<TerminalReader> TerminalReaders =>
_terminalReaders ??= new Repository<TerminalReader>(_context);
// Expense Tracking / Accounts Payable // Expense Tracking / Accounts Payable
/// <summary>Repository for <see cref="Account"/> chart-of-accounts entries; supports self-referencing parent/child hierarchy.</summary> /// <summary>Repository for <see cref="Account"/> chart-of-accounts entries; supports self-referencing parent/child hierarchy.</summary>
public IRepository<Account> Accounts => public IRepository<Account> Accounts =>
@@ -1,5 +1,6 @@
using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using PowderCoating.Application.DTOs.Terminal;
using PowderCoating.Application.Interfaces; using PowderCoating.Application.Interfaces;
using PowderCoating.Core.Enums; using PowderCoating.Core.Enums;
using PowderCoating.Core.Interfaces; using PowderCoating.Core.Interfaces;
@@ -253,4 +254,258 @@ public class StripeConnectService : IStripeConnectService
} }
} }
// ===== Stripe Terminal (in-person card payments, WisePOS E) =====
/// <summary>True when the Connect secret key is a test-mode key (sk_test_…).</summary>
private bool IsTestMode => SecretKey.StartsWith("sk_test_", StringComparison.Ordinal);
/// <inheritdoc/>
public async Task<(bool Success, string? LocationId, string? ErrorMessage)> CreateTerminalLocationAsync(
string connectedAccountId,
string displayName,
TerminalAddressDto address)
{
try
{
var options = new Stripe.Terminal.LocationCreateOptions
{
DisplayName = displayName,
Address = new AddressOptions
{
Line1 = address.Line1,
City = address.City,
State = address.State,
PostalCode = address.PostalCode,
Country = address.Country
}
};
var client = new StripeClient(SecretKey);
var requestOptions = new RequestOptions { StripeAccount = connectedAccountId };
var service = new Stripe.Terminal.LocationService(client);
var location = await service.CreateAsync(options, requestOptions);
return (true, location.Id, null);
}
catch (StripeException ex)
{
_logger.LogError(ex, "Failed to create Terminal Location for account {AccountId}", connectedAccountId);
return (false, null, ex.StripeError?.Message ?? ex.Message);
}
}
/// <inheritdoc/>
public async Task<(bool Success, string? ReaderId, string? DeviceType, string? SerialNumber, string? ErrorMessage)> RegisterReaderAsync(
string connectedAccountId,
string locationId,
string registrationCode,
string label)
{
try
{
var options = new Stripe.Terminal.ReaderCreateOptions
{
RegistrationCode = registrationCode,
Label = label,
Location = locationId
};
var client = new StripeClient(SecretKey);
var requestOptions = new RequestOptions { StripeAccount = connectedAccountId };
var service = new Stripe.Terminal.ReaderService(client);
var reader = await service.CreateAsync(options, requestOptions);
return (true, reader.Id, reader.DeviceType, reader.SerialNumber, null);
}
catch (StripeException ex)
{
_logger.LogError(ex, "Failed to register Terminal reader for account {AccountId}", connectedAccountId);
return (false, null, null, null, ex.StripeError?.Message ?? ex.Message);
}
}
/// <inheritdoc/>
public async Task<(bool Success, IReadOnlyList<TerminalReaderDto> Readers, string? ErrorMessage)> ListReadersAsync(
string connectedAccountId,
string locationId)
{
try
{
var options = new Stripe.Terminal.ReaderListOptions { Location = locationId };
var client = new StripeClient(SecretKey);
var requestOptions = new RequestOptions { StripeAccount = connectedAccountId };
var service = new Stripe.Terminal.ReaderService(client);
var readers = await service.ListAsync(options, requestOptions);
var dtos = readers.Data.Select(r => new TerminalReaderDto
{
StripeReaderId = r.Id,
Label = r.Label,
DeviceType = r.DeviceType,
SerialNumber = r.SerialNumber,
NetworkStatus = r.Status
}).ToList();
return (true, dtos, null);
}
catch (StripeException ex)
{
_logger.LogError(ex, "Failed to list Terminal readers for account {AccountId}", connectedAccountId);
return (false, Array.Empty<TerminalReaderDto>(), ex.StripeError?.Message ?? ex.Message);
}
}
/// <inheritdoc/>
public async Task<(bool Success, string? ErrorMessage)> DeleteReaderAsync(
string connectedAccountId,
string readerId)
{
try
{
var client = new StripeClient(SecretKey);
var requestOptions = new RequestOptions { StripeAccount = connectedAccountId };
var service = new Stripe.Terminal.ReaderService(client);
await service.DeleteAsync(readerId, null, requestOptions);
return (true, null);
}
catch (StripeException ex)
{
_logger.LogError(ex, "Failed to delete Terminal reader {ReaderId}", readerId);
return (false, ex.StripeError?.Message ?? ex.Message);
}
}
/// <inheritdoc/>
public async Task<(bool Success, string? PaymentIntentId, string? ErrorMessage)> ProcessInvoicePaymentOnReaderAsync(
string connectedAccountId,
string readerId,
decimal amount,
decimal surchargeAmount,
string currency,
string invoiceNumber,
int invoiceId)
{
try
{
var totalWithSurcharge = amount + surchargeAmount;
var amountInCents = (long)Math.Round(totalWithSurcharge * 100, MidpointRounding.AwayFromZero);
var client = new StripeClient(SecretKey);
var requestOptions = new RequestOptions { StripeAccount = connectedAccountId };
// 1) Create a card_present PaymentIntent. Note: do NOT set AutomaticPaymentMethods here —
// it is incompatible with an explicit card_present payment method type.
var piOptions = new PaymentIntentCreateOptions
{
Amount = amountInCents,
Currency = currency.ToLower(),
Description = $"Invoice {invoiceNumber}",
PaymentMethodTypes = new List<string> { "card_present" },
CaptureMethod = "automatic",
Metadata = new Dictionary<string, string>
{
{ "invoice_id", invoiceId.ToString() },
{ "invoice_number", invoiceNumber },
{ "surcharge_amount", surchargeAmount.ToString("F2") },
{ "source", "terminal" }
}
};
var piService = new PaymentIntentService(client);
var intent = await piService.CreateAsync(piOptions, requestOptions);
// 2) Push the PaymentIntent to the physical reader; it prompts the customer to present a card.
var processOptions = new Stripe.Terminal.ReaderProcessPaymentIntentOptions
{
PaymentIntent = intent.Id,
ProcessConfig = new Stripe.Terminal.ReaderProcessConfigOptions
{
EnableCustomerCancellation = true,
SkipTipping = true
}
};
var readerService = new Stripe.Terminal.ReaderService(client);
await readerService.ProcessPaymentIntentAsync(readerId, processOptions, requestOptions);
return (true, intent.Id, null);
}
catch (StripeException ex)
{
_logger.LogError(ex, "Failed to process Terminal payment for invoice {InvoiceId} on reader {ReaderId}", invoiceId, readerId);
return (false, null, ex.StripeError?.Message ?? ex.Message);
}
}
/// <inheritdoc/>
public async Task<(bool Success, string? ActionStatus, string? ActionType, string? PaymentIntentId,
string? FailureCode, string? FailureMessage, string? NetworkStatus, string? ErrorMessage)> GetReaderStatusAsync(
string connectedAccountId,
string readerId)
{
try
{
var client = new StripeClient(SecretKey);
var requestOptions = new RequestOptions { StripeAccount = connectedAccountId };
var service = new Stripe.Terminal.ReaderService(client);
var reader = await service.GetAsync(readerId, null, requestOptions);
var action = reader.Action;
return (
true,
action?.Status,
action?.Type,
action?.ProcessPaymentIntent?.PaymentIntentId,
action?.FailureCode,
action?.FailureMessage,
reader.Status,
null);
}
catch (StripeException ex)
{
_logger.LogError(ex, "Failed to read Terminal reader status {ReaderId}", readerId);
return (false, null, null, null, null, null, null, ex.StripeError?.Message ?? ex.Message);
}
}
/// <inheritdoc/>
public async Task<(bool Success, string? ErrorMessage)> CancelReaderActionAsync(
string connectedAccountId,
string readerId)
{
try
{
var client = new StripeClient(SecretKey);
var requestOptions = new RequestOptions { StripeAccount = connectedAccountId };
var service = new Stripe.Terminal.ReaderService(client);
await service.CancelActionAsync(readerId, null, requestOptions);
return (true, null);
}
catch (StripeException ex)
{
_logger.LogError(ex, "Failed to cancel Terminal reader action {ReaderId}", readerId);
return (false, ex.StripeError?.Message ?? ex.Message);
}
}
/// <inheritdoc/>
public async Task<(bool Success, string? ErrorMessage)> SimulatePresentPaymentMethodAsync(
string connectedAccountId,
string readerId)
{
if (!IsTestMode)
return (false, "Simulated card taps are only available in Stripe test mode.");
try
{
var client = new StripeClient(SecretKey);
var requestOptions = new RequestOptions { StripeAccount = connectedAccountId };
var service = new Stripe.TestHelpers.Terminal.ReaderService(client);
await service.PresentPaymentMethodAsync(readerId, new Stripe.TestHelpers.Terminal.ReaderPresentPaymentMethodOptions(), requestOptions);
return (true, null);
}
catch (StripeException ex)
{
_logger.LogError(ex, "Failed to simulate card tap on reader {ReaderId}", readerId);
return (false, ex.StripeError?.Message ?? ex.Message);
}
}
} }
@@ -158,6 +158,11 @@ public class CompanySettingsController : Controller
ViewBag.StripeConnectConfigured = !string.IsNullOrWhiteSpace(connectClientId) ViewBag.StripeConnectConfigured = !string.IsNullOrWhiteSpace(connectClientId)
&& !connectClientId.Contains("your_connect_client_id_here", StringComparison.OrdinalIgnoreCase); && !connectClientId.Contains("your_connect_client_id_here", StringComparison.OrdinalIgnoreCase);
// Stripe Terminal (Card Readers tab) — current in-person surcharge toggle + test-mode flag
ViewBag.TerminalSurchargeEnabled = company.TerminalSurchargeEnabled;
ViewBag.TerminalTestMode =
(_configuration["Stripe:Connect:SecretKey"] ?? string.Empty).StartsWith("sk_test_", StringComparison.Ordinal);
// Load notification templates for inline tab // Load notification templates for inline tab
var existing = await _unitOfWork.NotificationTemplates.FindAsync(t => t.CompanyId == companyId.Value); var existing = await _unitOfWork.NotificationTemplates.FindAsync(t => t.CompanyId == companyId.Value);
var seeded = await EnsureNotificationTemplatesSeededAsync(companyId.Value, existing.ToList()); var seeded = await EnsureNotificationTemplatesSeededAsync(companyId.Value, existing.ToList());
@@ -30,6 +30,7 @@ public class InvoicesController : Controller
private readonly INotificationService _notificationService; private readonly INotificationService _notificationService;
private readonly IAccountBalanceService _accountBalanceService; private readonly IAccountBalanceService _accountBalanceService;
private readonly ICompanyLogoService _logoService; private readonly ICompanyLogoService _logoService;
private readonly IConfiguration _configuration;
public InvoicesController( public InvoicesController(
IUnitOfWork unitOfWork, IUnitOfWork unitOfWork,
@@ -40,7 +41,8 @@ public class InvoicesController : Controller
ITenantContext tenantContext, ITenantContext tenantContext,
INotificationService notificationService, INotificationService notificationService,
IAccountBalanceService accountBalanceService, IAccountBalanceService accountBalanceService,
ICompanyLogoService logoService) ICompanyLogoService logoService,
IConfiguration configuration)
{ {
_unitOfWork = unitOfWork; _unitOfWork = unitOfWork;
_mapper = mapper; _mapper = mapper;
@@ -51,6 +53,7 @@ public class InvoicesController : Controller
_notificationService = notificationService; _notificationService = notificationService;
_accountBalanceService = accountBalanceService; _accountBalanceService = accountBalanceService;
_logoService = logoService; _logoService = logoService;
_configuration = configuration;
} }
private static readonly string[] StandardPaymentTerms = private static readonly string[] StandardPaymentTerms =
@@ -82,15 +85,14 @@ public class InvoicesController : Controller
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------
/// <summary> /// <summary>
/// Displays the paginated invoice list with multi-mode filtering. The filter cascade handles /// Displays the paginated invoice list with multi-mode filtering. The filter cascade handles
/// statusGroup pills (unpaid/partial/paid/all) plus legacy flag combinations (overdue/outstanding/thisMonth) /// nine combinations of overdue/outstanding/thisMonth flags with status and search term so the
/// so the database receives a single targeted predicate — no full-table load then in-memory LINQ. /// database receives a single targeted predicate — no full-table load then in-memory LINQ.
/// Balance-due sort is computed in the ORDER BY expression rather than a stored column because /// Balance-due sort is computed in the ORDER BY expression rather than a stored column because
/// balance = Total AmountPaid CreditApplied GiftCertificateRedeemed changes on every payment. /// balance = Total AmountPaid CreditApplied GiftCertificateRedeemed changes on every payment.
/// </summary> /// </summary>
public async Task<IActionResult> Index( public async Task<IActionResult> Index(
string? searchTerm, string? searchTerm,
InvoiceStatus? statusFilter, InvoiceStatus? statusFilter,
string? statusGroup,
string? sortColumn, string? sortColumn,
string sortDirection = "desc", string sortDirection = "desc",
bool outstandingOnly = false, bool outstandingOnly = false,
@@ -101,11 +103,6 @@ public class InvoicesController : Controller
{ {
try try
{ {
// Default landing: show unpaid invoices so the list is immediately actionable.
if (string.IsNullOrEmpty(statusGroup) && !statusFilter.HasValue &&
string.IsNullOrEmpty(searchTerm) && !outstandingOnly && !thisMonthOnly && !overdueOnly)
return RedirectToAction("Index", new { statusGroup = "unpaid" });
var today = DateTime.Today; var today = DateTime.Today;
var startOfMonth = new DateTime(today.Year, today.Month, 1); var startOfMonth = new DateTime(today.Year, today.Month, 1);
var endOfMonth = startOfMonth.AddMonths(1); var endOfMonth = startOfMonth.AddMonths(1);
@@ -122,18 +119,7 @@ public class InvoicesController : Controller
System.Linq.Expressions.Expression<Func<Invoice, bool>>? filter = null; System.Linq.Expressions.Expression<Func<Invoice, bool>>? filter = null;
// Status-group pills take priority over the dropdown and legacy flags. if (overdueOnly)
if (!string.IsNullOrEmpty(statusGroup))
{
filter = statusGroup switch
{
"unpaid" => i => i.Status == InvoiceStatus.Draft || i.Status == InvoiceStatus.Sent || i.Status == InvoiceStatus.Overdue,
"partial" => i => i.Status == InvoiceStatus.PartiallyPaid,
"paid" => i => i.Status == InvoiceStatus.Paid,
_ => null // "all" — no predicate
};
}
else if (overdueOnly)
{ {
filter = i => (i.Status == InvoiceStatus.Sent || i.Status == InvoiceStatus.PartiallyPaid || i.Status == InvoiceStatus.Overdue) filter = i => (i.Status == InvoiceStatus.Sent || i.Status == InvoiceStatus.PartiallyPaid || i.Status == InvoiceStatus.Overdue)
&& i.DueDate.HasValue && i.DueDate.Value < today; && i.DueDate.HasValue && i.DueDate.Value < today;
@@ -232,20 +218,12 @@ public class InvoicesController : Controller
ViewBag.SearchTerm = searchTerm; ViewBag.SearchTerm = searchTerm;
ViewBag.StatusFilter = statusFilter; ViewBag.StatusFilter = statusFilter;
ViewBag.StatusGroup = statusGroup;
ViewBag.OutstandingOnly = outstandingOnly; ViewBag.OutstandingOnly = outstandingOnly;
ViewBag.ThisMonthOnly = thisMonthOnly; ViewBag.ThisMonthOnly = thisMonthOnly;
ViewBag.OverdueOnly = overdueOnly; ViewBag.OverdueOnly = overdueOnly;
ViewBag.SortColumn = gridRequest.SortColumn; ViewBag.SortColumn = gridRequest.SortColumn;
ViewBag.SortDirection = gridRequest.SortDirection; ViewBag.SortDirection = gridRequest.SortDirection;
// Pill badge counts — always global (not scoped to current filter/page)
ViewBag.UnpaidCount = await _unitOfWork.Invoices.CountAsync(i =>
i.Status == InvoiceStatus.Draft || i.Status == InvoiceStatus.Sent || i.Status == InvoiceStatus.Overdue);
ViewBag.PartialCount = await _unitOfWork.Invoices.CountAsync(i => i.Status == InvoiceStatus.PartiallyPaid);
ViewBag.PaidCount = await _unitOfWork.Invoices.CountAsync(i => i.Status == InvoiceStatus.Paid);
ViewBag.AllCount = await _unitOfWork.Invoices.CountAsync();
return View(pagedResult); return View(pagedResult);
} }
catch (Exception ex) catch (Exception ex)
@@ -303,6 +281,20 @@ public class InvoicesController : Controller
ViewBag.OnlinePaymentsEnabled = onlinePaymentsAllowed ViewBag.OnlinePaymentsEnabled = onlinePaymentsAllowed
&& company?.StripeConnectStatus == StripeConnectStatus.Active; && company?.StripeConnectStatus == StripeConnectStatus.Active;
// In-person card reader (Stripe Terminal) — bundled with the online-payments entitlement.
// Surface the active readers + a "Take Card Payment" button only when at least one exists.
var terminalReaders = (await _unitOfWork.TerminalReaders.FindAsync(
r => r.Status == Core.Enums.TerminalReaderStatus.Active))
.OrderBy(r => r.Label)
.Select(r => new SelectListItem(r.Label, r.Id.ToString()))
.ToList();
ViewBag.TerminalReaders = terminalReaders;
ViewBag.TerminalPaymentsEnabled = onlinePaymentsAllowed
&& company?.StripeConnectStatus == StripeConnectStatus.Active
&& terminalReaders.Count > 0;
ViewBag.TerminalTestMode =
(_configuration["Stripe:Connect:SecretKey"] ?? string.Empty).StartsWith("sk_test_", StringComparison.Ordinal);
// Expense accounts for the write-off bad-debt modal // Expense accounts for the write-off bad-debt modal
var expenseAccounts = await _unitOfWork.Accounts.FindAsync( var expenseAccounts = await _unitOfWork.Accounts.FindAsync(
a => a.IsActive && a.AccountType == AccountType.Expense); a => a.IsActive && a.AccountType == AccountType.Expense);
@@ -1335,17 +1327,14 @@ public class InvoicesController : Controller
}); // end ExecuteInTransactionAsync }); // end ExecuteInTransactionAsync
// Notify (non-blocking) — skipped if user explicitly suppressed it // Notify (non-blocking)
if (!dto.SuppressNotification) try
{ {
try await _notificationService.NotifyPaymentReceivedAsync(invoice, payment);
{ }
await _notificationService.NotifyPaymentReceivedAsync(invoice, payment); catch (Exception notifyEx)
} {
catch (Exception notifyEx) _logger.LogWarning(notifyEx, "Payment recorded but notification failed");
{
_logger.LogWarning(notifyEx, "Payment recorded but notification failed");
}
} }
@@ -381,6 +381,11 @@ public class PaymentController : Controller
var dispute = stripeEvent.Data.Object as Dispute; var dispute = stripeEvent.Data.Object as Dispute;
if (dispute != null) await HandleDisputeClosedAsync(dispute); if (dispute != null) await HandleDisputeClosedAsync(dispute);
} }
else if (stripeEvent.Type == "terminal.reader.action_failed")
{
var reader = stripeEvent.Data.Object as Stripe.Terminal.Reader;
if (reader != null) await HandleReaderActionFailedAsync(reader);
}
return Ok(); return Ok();
} }
@@ -459,15 +464,24 @@ public class PaymentController : Controller
// Create a Payment record so the payment appears in AR and bank reports, and make the // Create a Payment record so the payment appears in AR and bank reports, and make the
// matching GL entries. Manual payments go through RecordPayment which does the same thing; // matching GL entries. Manual payments go through RecordPayment which does the same thing;
// this makes Stripe payments consistent with that path. // this makes Stripe payments consistent with that path.
// In-person Terminal payments carry source=terminal so we can record them as a card-reader
// payment (vs an online card-not-present payment) for clearer reporting. Everything else —
// GL posting, status machine, notifications — is identical.
var isTerminal = intent.Metadata.GetValueOrDefault("source") == "terminal";
var (arAcctId, checkingAcctId) = await GetGlAccountIdsAsync(invoice.CompanyId); var (arAcctId, checkingAcctId) = await GetGlAccountIdsAsync(invoice.CompanyId);
var stripePayment = new Core.Entities.Payment var stripePayment = new Core.Entities.Payment
{ {
InvoiceId = invoice.Id, InvoiceId = invoice.Id,
Amount = netPayment, Amount = netPayment,
PaymentDate = DateTime.UtcNow, PaymentDate = DateTime.UtcNow,
PaymentMethod = PowderCoating.Core.Enums.PaymentMethod.CreditDebitCard, PaymentMethod = isTerminal
? PowderCoating.Core.Enums.PaymentMethod.CardReader
: PowderCoating.Core.Enums.PaymentMethod.CreditDebitCard,
Reference = intent.Id, Reference = intent.Id,
Notes = $"Online payment via Stripe. Surcharge: {surcharge:C}", Notes = isTerminal
? $"In-person card payment via Stripe Terminal. Surcharge: {surcharge:C}"
: $"Online payment via Stripe. Surcharge: {surcharge:C}",
DepositAccountId = checkingAcctId, DepositAccountId = checkingAcctId,
CompanyId = invoice.CompanyId, CompanyId = invoice.CompanyId,
CreatedAt = DateTime.UtcNow CreatedAt = DateTime.UtcNow
@@ -502,6 +516,45 @@ public class PaymentController : Controller
} }
} }
/// <summary>
/// Handles a <c>terminal.reader.action_failed</c> event (declined card, customer cancellation,
/// reader timeout). This is observability only — no payment occurred, so nothing is written to the
/// ledger. The clerk's live status poll is the primary feedback channel; this fires an in-app
/// notification as a backstop in case they navigated away. Resolves the company via the invoice the
/// failed PaymentIntent was created for. Uses <c>IgnoreQueryFilters</c> (no tenant context here).
/// </summary>
private async Task HandleReaderActionFailedAsync(Stripe.Terminal.Reader reader)
{
var failureMessage = reader.Action?.FailureMessage ?? "The card reader payment did not complete.";
var paymentIntentId = reader.Action?.ProcessPaymentIntent?.PaymentIntentId;
_logger.LogWarning("Terminal reader {ReaderId} action failed: {Code} {Message} (PI={PI})",
reader.Id, reader.Action?.FailureCode, failureMessage, paymentIntentId);
if (string.IsNullOrEmpty(paymentIntentId)) return;
var invoice = await _context.Invoices
.IgnoreQueryFilters()
.FirstOrDefaultAsync(i => i.StripePaymentIntentId == paymentIntentId && !i.IsDeleted);
if (invoice == null) return;
try
{
await _inApp.CreateAsync(
companyId: invoice.CompanyId,
title: "Card Reader Payment Failed",
message: $"The card reader payment for invoice {invoice.InvoiceNumber} failed: {failureMessage}",
notificationType: "PaymentFailed",
link: $"/Invoices/Details/{invoice.Id}",
invoiceId: invoice.Id,
customerId: invoice.CustomerId);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "In-app notification failed for terminal failure on invoice {InvoiceId}", invoice.Id);
}
}
/// <summary> /// <summary>
/// Processes a successful <c>payment_intent.succeeded</c> event for a quote deposit. Creates a /// Processes a successful <c>payment_intent.succeeded</c> event for a quote deposit. Creates a
/// <c>Deposit</c> ledger record so the deposit appears in the customer's deposit history and can /// <c>Deposit</c> ledger record so the deposit appears in the customer's deposit history and can
@@ -0,0 +1,342 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using PowderCoating.Application.DTOs.Terminal;
using PowderCoating.Application.Interfaces;
using PowderCoating.Core.Entities;
using PowderCoating.Core.Enums;
using PowderCoating.Core.Interfaces;
using PowderCoating.Shared.Constants;
namespace PowderCoating.Web.Controllers;
/// <summary>
/// Authenticated, tenant-scoped controller for Stripe Terminal in-person card payments (WisePOS E).
/// Handles reader registration/management (admin) and pushing an invoice payment to a physical reader
/// plus live status polling (anyone who can manage invoices).
/// <para>
/// The authoritative payment record is always created by the existing <c>payment_intent.succeeded</c>
/// webhook in <see cref="PaymentController"/> — this controller only kicks off the charge on the reader
/// and reports progress. See <c>docs</c>/the plan for the full flow.
/// </para>
/// </summary>
[Authorize(Policy = AppConstants.Policies.CanManageInvoices)]
public class TerminalController : Controller
{
private readonly IUnitOfWork _unitOfWork;
private readonly IStripeConnectService _stripeConnect;
private readonly ITenantContext _tenantContext;
private readonly IConfiguration _configuration;
private readonly ILogger<TerminalController> _logger;
public TerminalController(
IUnitOfWork unitOfWork,
IStripeConnectService stripeConnect,
ITenantContext tenantContext,
IConfiguration configuration,
ILogger<TerminalController> logger)
{
_unitOfWork = unitOfWork;
_stripeConnect = stripeConnect;
_tenantContext = tenantContext;
_configuration = configuration;
_logger = logger;
}
/// <summary>Current tenant's company id. The CanManageInvoices policy guarantees a company-scoped user;
/// a 0 fallback fails safe (matches no company) for the theoretical claim-less case.</summary>
private int CompanyId => _tenantContext.GetCurrentCompanyId() ?? 0;
/// <summary>True when the Connect secret key is a test-mode key — gates the simulated-tap endpoint.</summary>
private bool IsTestMode =>
(_configuration["Stripe:Connect:SecretKey"] ?? string.Empty).StartsWith("sk_test_", StringComparison.Ordinal);
// ===== Reader management (admin) =====
/// <summary>
/// Registers a Stripe Terminal reader to the company using the registration code shown on the
/// device. Lazily creates the company's single Terminal Location from its address on first use.
/// Requires company-admin rights in addition to the controller's invoice policy.
/// </summary>
[HttpPost, ValidateAntiForgeryToken]
[Authorize(Policy = AppConstants.Policies.CompanyAdminOnly)]
public async Task<IActionResult> RegisterReader(string registrationCode, string label)
{
if (string.IsNullOrWhiteSpace(registrationCode) || string.IsNullOrWhiteSpace(label))
return Json(new { success = false, error = "A registration code and a label are both required." });
var companyId = CompanyId;
var company = await _unitOfWork.Companies.GetByIdAsync(companyId);
if (company == null)
return Json(new { success = false, error = "Company not found." });
if (company.StripeConnectStatus != StripeConnectStatus.Active || string.IsNullOrEmpty(company.StripeAccountId))
return Json(new { success = false, error = "Connect your Stripe account before registering a reader." });
// Ensure the shop's Terminal Location exists (one per company).
var (locOk, locationId, locError) = await EnsureLocationAsync(company);
if (!locOk)
return Json(new { success = false, error = locError });
var (ok, readerId, deviceType, serial, error) = await _stripeConnect.RegisterReaderAsync(
company.StripeAccountId!, locationId!, registrationCode.Trim(), label.Trim());
if (!ok)
return Json(new { success = false, error });
var reader = new TerminalReader
{
CompanyId = companyId,
StripeReaderId = readerId!,
StripeLocationId = locationId!,
Label = label.Trim(),
DeviceType = deviceType ?? string.Empty,
SerialNumber = serial,
Status = TerminalReaderStatus.Active
};
await _unitOfWork.TerminalReaders.AddAsync(reader);
await _unitOfWork.CompleteAsync();
return Json(new { success = true, reader = ToJson(reader) });
}
/// <summary>
/// Saves the in-person (Terminal) surcharge toggle. Defaults off; enabling it applies the same
/// percent/flat fee configured for online payments to card-reader charges. Admin only.
/// </summary>
[HttpPost, ValidateAntiForgeryToken]
[Authorize(Policy = AppConstants.Policies.CompanyAdminOnly)]
public async Task<IActionResult> UpdateTerminalSettings(bool surchargeEnabled)
{
var companyId = CompanyId;
var company = await _unitOfWork.Companies.GetByIdAsync(companyId);
if (company == null)
return Json(new { success = false, error = "Company not found." });
company.TerminalSurchargeEnabled = surchargeEnabled;
company.UpdatedAt = DateTime.UtcNow;
await _unitOfWork.Companies.UpdateAsync(company);
await _unitOfWork.CompleteAsync();
return Json(new { success = true });
}
/// <summary>Returns the company's active registered readers (JSON) for the settings tab.</summary>
[HttpGet]
public async Task<IActionResult> ListReaders()
{
var companyId = CompanyId;
var readers = await _unitOfWork.TerminalReaders.FindAsync(
r => r.CompanyId == companyId && r.Status == TerminalReaderStatus.Active);
return Json(new { success = true, readers = readers.OrderBy(r => r.Label).Select(ToJson) });
}
/// <summary>Unregisters a reader from Stripe and soft-deletes the local record.</summary>
[HttpPost, ValidateAntiForgeryToken]
[Authorize(Policy = AppConstants.Policies.CompanyAdminOnly)]
public async Task<IActionResult> DeactivateReader(int id)
{
var companyId = CompanyId;
var reader = await _unitOfWork.TerminalReaders.GetByIdAsync(id);
if (reader == null || reader.CompanyId != companyId)
return Json(new { success = false, error = "Reader not found." });
var company = await _unitOfWork.Companies.GetByIdAsync(companyId);
if (company?.StripeAccountId != null)
{
// Best-effort delete on Stripe; proceed with local cleanup even if it has already been removed there.
var (ok, error) = await _stripeConnect.DeleteReaderAsync(company.StripeAccountId, reader.StripeReaderId);
if (!ok)
_logger.LogWarning("Stripe reader delete failed for {ReaderId}: {Error}", reader.StripeReaderId, error);
}
reader.Status = TerminalReaderStatus.Deactivated;
await _unitOfWork.TerminalReaders.SoftDeleteAsync(reader);
await _unitOfWork.CompleteAsync();
return Json(new { success = true });
}
// ===== Taking a payment =====
/// <summary>
/// Creates a card_present PaymentIntent for the invoice and pushes it to the selected reader.
/// Stores the returned PaymentIntent id on the invoice so the webhook's idempotency guard works.
/// Does NOT record the payment — the <c>payment_intent.succeeded</c> webhook is authoritative.
/// </summary>
[HttpPost, ValidateAntiForgeryToken]
public async Task<IActionResult> ProcessPayment(int invoiceId, int readerId, decimal amount)
{
var companyId = CompanyId;
var company = await _unitOfWork.Companies.GetByIdAsync(companyId);
if (company == null || company.StripeConnectStatus != StripeConnectStatus.Active || string.IsNullOrEmpty(company.StripeAccountId))
return Json(new { success = false, error = "Stripe is not connected for this company." });
var invoice = await _unitOfWork.Invoices.GetByIdAsync(invoiceId);
if (invoice == null || invoice.CompanyId != companyId)
return Json(new { success = false, error = "Invoice not found." });
if (invoice.Status == InvoiceStatus.Voided)
return Json(new { success = false, error = "This invoice has been voided." });
if (invoice.BalanceDue <= 0)
return Json(new { success = false, error = "This invoice is already paid in full." });
if (amount <= 0 || amount > invoice.BalanceDue)
return Json(new { success = false, error = "Enter an amount between $0 and the balance due." });
var reader = await _unitOfWork.TerminalReaders.GetByIdAsync(readerId);
if (reader == null || reader.CompanyId != companyId || reader.Status != TerminalReaderStatus.Active)
return Json(new { success = false, error = "Card reader not found." });
// In-person surcharge is OFF unless the shop has explicitly enabled it (compliance varies by state).
var surcharge = company.TerminalSurchargeEnabled ? CalculateSurcharge(amount, company) : 0m;
var (ok, paymentIntentId, error) = await _stripeConnect.ProcessInvoicePaymentOnReaderAsync(
company.StripeAccountId!, reader.StripeReaderId, amount, surcharge, "usd", invoice.InvoiceNumber, invoice.Id);
if (!ok)
return Json(new { success = false, error });
// Persist the PI id so HandlePaymentSucceededAsync's idempotency guard matches (mirrors PaymentController.CreateIntent).
invoice.StripePaymentIntentId = paymentIntentId;
if (invoice.OnlinePaymentStatus == OnlinePaymentStatus.NotApplicable)
invoice.OnlinePaymentStatus = OnlinePaymentStatus.Pending;
invoice.UpdatedAt = DateTime.UtcNow;
await _unitOfWork.Invoices.UpdateAsync(invoice);
await _unitOfWork.CompleteAsync();
return Json(new { success = true, paymentIntentId });
}
/// <summary>
/// Polls the reader's action status for live UI feedback and reports whether the webhook has
/// already recorded the payment (derived from the invoice's online payment status for this PI).
/// </summary>
[HttpGet]
public async Task<IActionResult> PaymentStatus(int readerId, string paymentIntentId)
{
var companyId = CompanyId;
var reader = await _unitOfWork.TerminalReaders.GetByIdAsync(readerId);
if (reader == null || reader.CompanyId != companyId)
return Json(new { success = false, error = "Card reader not found." });
var company = await _unitOfWork.Companies.GetByIdAsync(companyId);
if (company?.StripeAccountId == null)
return Json(new { success = false, error = "Stripe is not connected." });
var status = await _stripeConnect.GetReaderStatusAsync(company.StripeAccountId, reader.StripeReaderId);
// The webhook is the source of truth — check whether it has landed for this PaymentIntent.
var invoice = (await _unitOfWork.Invoices.FindAsync(
i => i.CompanyId == companyId && i.StripePaymentIntentId == paymentIntentId)).FirstOrDefault();
var webhookRecorded = invoice != null
&& invoice.OnlinePaymentStatus is OnlinePaymentStatus.Paid or OnlinePaymentStatus.PartiallyPaid;
return Json(new
{
success = status.Success,
actionStatus = status.ActionStatus,
failureCode = status.FailureCode,
failureMessage = status.FailureMessage,
webhookRecorded,
error = status.ErrorMessage
});
}
/// <summary>Cancels an in-progress reader action (clerk cancelled or wants to retry).</summary>
[HttpPost, ValidateAntiForgeryToken]
public async Task<IActionResult> CancelPayment(int readerId)
{
var companyId = CompanyId;
var reader = await _unitOfWork.TerminalReaders.GetByIdAsync(readerId);
if (reader == null || reader.CompanyId != companyId)
return Json(new { success = false, error = "Card reader not found." });
var company = await _unitOfWork.Companies.GetByIdAsync(companyId);
if (company?.StripeAccountId == null)
return Json(new { success = false, error = "Stripe is not connected." });
var (ok, error) = await _stripeConnect.CancelReaderActionAsync(company.StripeAccountId, reader.StripeReaderId);
return Json(new { success = ok, error });
}
/// <summary>
/// TEST MODE ONLY: simulates a card tap on a simulated reader so a payment can complete without
/// hardware. Returns 404 in production so the endpoint cannot be probed there.
/// </summary>
[HttpPost, ValidateAntiForgeryToken]
public async Task<IActionResult> SimulateTap(int readerId)
{
if (!IsTestMode)
return NotFound();
var companyId = CompanyId;
var reader = await _unitOfWork.TerminalReaders.GetByIdAsync(readerId);
if (reader == null || reader.CompanyId != companyId)
return Json(new { success = false, error = "Card reader not found." });
var company = await _unitOfWork.Companies.GetByIdAsync(companyId);
if (company?.StripeAccountId == null)
return Json(new { success = false, error = "Stripe is not connected." });
var (ok, error) = await _stripeConnect.SimulatePresentPaymentMethodAsync(company.StripeAccountId, reader.StripeReaderId);
return Json(new { success = ok, error });
}
// ===== Helpers =====
/// <summary>
/// Ensures the company has a Stripe Terminal Location, creating one from its address if needed and
/// persisting the id. Returns the location id to attach readers to.
/// </summary>
private async Task<(bool Success, string? LocationId, string? Error)> EnsureLocationAsync(Company company)
{
if (!string.IsNullOrEmpty(company.StripeTerminalLocationId))
return (true, company.StripeTerminalLocationId, null);
if (string.IsNullOrWhiteSpace(company.Address) || string.IsNullOrWhiteSpace(company.City)
|| string.IsNullOrWhiteSpace(company.State) || string.IsNullOrWhiteSpace(company.ZipCode))
{
return (false, null, "Complete your company address (street, city, state, ZIP) before registering a reader.");
}
var address = new TerminalAddressDto
{
Line1 = company.Address!,
City = company.City!,
State = company.State!,
PostalCode = company.ZipCode!,
Country = "US"
};
var (ok, locationId, error) = await _stripeConnect.CreateTerminalLocationAsync(
company.StripeAccountId!, company.CompanyName, address);
if (!ok)
return (false, null, error);
company.StripeTerminalLocationId = locationId;
company.UpdatedAt = DateTime.UtcNow;
await _unitOfWork.Companies.UpdateAsync(company);
await _unitOfWork.CompleteAsync();
return (true, locationId, null);
}
/// <summary>Mirrors the online surcharge calculation (percent/flat) used by PaymentController.</summary>
private static decimal CalculateSurcharge(decimal amount, Company company)
{
return company.OnlinePaymentSurchargeType switch
{
OnlinePaymentSurchargeType.Percent => Math.Round(amount * (company.OnlinePaymentSurchargeValue / 100m), 2),
OnlinePaymentSurchargeType.Flat => company.OnlinePaymentSurchargeValue,
_ => 0m
};
}
/// <summary>Projects a reader to the anonymous shape returned to the settings tab JS.</summary>
private static object ToJson(TerminalReader r) => new
{
id = r.Id,
label = r.Label,
deviceType = r.DeviceType,
serialNumber = r.SerialNumber,
networkStatus = r.LastKnownNetworkStatus,
lastSeenAt = r.LastSeenAt
};
}
@@ -426,6 +426,14 @@ public static class HelpKnowledgeBase
- Payment is recorded automatically on the invoice and the invoice status updates to Paid or Partially Paid - Payment is recorded automatically on the invoice and the invoice status updates to Paid or Partially Paid
- The company receives a bell notification: "Online Payment Received" - The company receives a bell notification: "Online Payment Received"
**In-person card payments (Stripe Terminal WisePOS E):**
Take a card payment in person against an invoice using a Stripe Terminal **WisePOS E** card reader. This is included with the same plan entitlement as online payments and runs on the same connected Stripe account.
- **Setup (one-time):** Go to **Settings Card Readers** (the tab appears once Stripe is connected). On the reader, open **Settings Generate registration code** to get a three-word code, enter it with a label (e.g. "Front Counter") and click **Add Reader**.
- **Taking a payment:** On an invoice with a balance due, click **Take Card Payment**, pick the reader, confirm the amount, and click **Send to Reader**. The reader prompts the customer to tap, insert, or swipe. The screen shows live progress and refreshes the invoice when approved.
- The payment is recorded automatically (method: **Card Reader**) by the same Stripe webhook used for online payments it posts to your books and advances the invoice to Paid/Partially Paid. Partial payments are supported.
- **In-person surcharge** is OFF by default. It can be enabled on the Card Readers tab, but in-person surcharging is regulated differently than online and is prohibited in some states only enable it after confirming local rules.
- **Declines/cancellations** show an error on the reader and in the app; nothing is charged. Refunds use the normal Issue Refund flow.
**Voiding an invoice:** Invoice Details "Void" marks it voided. Cannot void a paid invoice. **Voiding an invoice:** Invoice Details "Void" marks it voided. Cannot void a paid invoice.
**Refunds:** Issue a refund from Invoice Details "Issue Refund." **Refunds:** Issue a refund from Invoice Details "Issue Refund."
@@ -113,7 +113,7 @@
const preview = document.getElementById('announcementPreview'); const preview = document.getElementById('announcementPreview');
preview.className = 'alert mb-0 ' + (typeMap[type] || 'alert-info'); preview.className = 'alert mb-0 ' + (typeMap[type] || 'alert-info');
document.getElementById('previewTitle').textContent = document.getElementById('Title').value || 'Title'; document.getElementById('previewTitle').textContent = document.getElementById('Title').value || 'Title';
document.getElementById('previewMessage').textContent = '\u2014' + (document.getElementById('Message').value || 'Message'); document.getElementById('previewMessage').textContent = ' &mdash; ' + (document.getElementById('Message').value || 'Message');
} }
document.getElementById('Type')?.addEventListener('change', updatePreview); document.getElementById('Type')?.addEventListener('change', updatePreview);
document.getElementById('Title')?.addEventListener('input', updatePreview); document.getElementById('Title')?.addEventListener('input', updatePreview);
@@ -598,7 +598,7 @@
const modal = bootstrap.Modal.getInstance(document.getElementById('scanReceiptModal')); const modal = bootstrap.Modal.getInstance(document.getElementById('scanReceiptModal'));
if (modal) modal.hide(); if (modal) modal.hide();
statusEl.innerHTML = 'Scan complete &mdash; review and adjust as needed.'; statusEl.textContent = 'Scan complete &mdash; review and adjust as needed.';
} catch (e) { } catch (e) {
statusEl.textContent = 'Error connecting to AI service.'; statusEl.textContent = 'Error connecting to AI service.';
} finally { } finally {
@@ -127,7 +127,6 @@
<div class="card shadow-sm mb-4"> <div class="card shadow-sm mb-4">
<div class="card-header fw-semibold">Line Items</div> <div class="card-header fw-semibold">Line Items</div>
<div class="card-body p-0"> <div class="card-body p-0">
<div class="table-responsive">
<table class="table table-sm mb-0"> <table class="table table-sm mb-0">
<thead class="table-light"> <thead class="table-light">
<tr> <tr>
@@ -170,7 +169,6 @@
</tr> </tr>
</tfoot> </tfoot>
</table> </table>
</div>
</div> </div>
</div> </div>
@@ -212,7 +210,6 @@
</a> </a>
</div> </div>
<div class="card-body p-0"> <div class="card-body p-0">
<div class="table-responsive">
<table class="table table-sm mb-0"> <table class="table table-sm mb-0">
<thead class="table-light"> <thead class="table-light">
<tr> <tr>
@@ -256,7 +253,6 @@
} }
</tbody> </tbody>
</table> </table>
</div>
</div> </div>
</div> </div>
} }
+78 -165
View File
@@ -92,181 +92,94 @@
{ {
<div class="card shadow-sm"> <div class="card shadow-sm">
<div class="card-body p-0"> <div class="card-body p-0">
<div class="table-responsive"> <table class="table table-hover mb-0">
<table class="table table-hover mb-0"> <thead class="table-light">
<thead class="table-light"> <tr>
<tr> <th style="width:90px">Type</th>
<th style="width:90px">Type</th> <th>Number</th>
<th>Number</th> <th>Vendor</th>
<th>Vendor</th> <th>Memo / Account</th>
<th>Memo / Account</th> <th>Date</th>
<th>Date</th> <th>Due Date</th>
<th>Due Date</th> <th>Status</th>
<th>Status</th> <th class="text-end">Amount</th>
<th class="text-end">Amount</th> <th class="text-end">Balance Due</th>
<th class="text-end">Balance Due</th> <th></th>
<th></th> </tr>
</tr> </thead>
</thead> <tbody>
<tbody>
@foreach (var entry in Model)
{
<tr class="@(entry.IsOverdue ? "table-warning" : "")">
<td>
@if (entry.EntryType == "Bill")
{
<span class="badge bg-primary-subtle text-primary border border-primary-subtle">
<i class="bi bi-file-text me-1"></i>Bill
</span>
}
else
{
<span class="badge bg-secondary-subtle text-secondary border border-secondary-subtle">
<i class="bi bi-receipt me-1"></i>Expense
</span>
}
</td>
<td>
@if (entry.EntryType == "Bill")
{
<a asp-controller="Bills" asp-action="Details" asp-route-id="@entry.Id"
class="fw-medium text-decoration-none">@entry.Number</a>
}
else
{
<a asp-controller="Expenses" asp-action="Details" asp-route-id="@entry.Id"
class="fw-medium text-decoration-none">@entry.Number</a>
}
</td>
<td>@entry.VendorName</td>
<td class="text-muted small">
@(entry.EntryType == "Bill" ? entry.Memo : entry.AccountName)
@if (entry.HasReceipt)
{
<i class="bi bi-paperclip ms-1" title="Has receipt"></i>
}
</td>
<td>@entry.Date.ToString("MMM d, yyyy")</td>
<td>
@if (entry.DueDate.HasValue)
{
<span class="@(entry.IsOverdue ? "text-danger fw-medium" : "")">
@entry.DueDate.Value.ToString("MMM d, yyyy")
@if (entry.IsOverdue) { <i class="bi bi-exclamation-circle ms-1"></i> }
</span>
}
else if (entry.EntryType == "Expense")
{
<span class="text-muted">&mdash;</span>
}
</td>
<td><span class="badge bg-@entry.StatusColor">@entry.StatusLabel</span></td>
<td class="text-end">@entry.Total.ToString("C")</td>
<td class="text-end fw-medium @(entry.BalanceDue > 0 ? "text-danger" : "text-muted")">
@Html.Raw(entry.EntryType == "Bill" ? entry.BalanceDue.ToString("C") : "&mdash;")
</td>
<td>
@if (entry.EntryType == "Bill")
{
<a asp-controller="Bills" asp-action="Details" asp-route-id="@entry.Id"
class="btn btn-sm btn-outline-primary"><i class="bi bi-eye"></i></a>
}
else
{
<a asp-controller="Expenses" asp-action="Details" asp-route-id="@entry.Id"
class="btn btn-sm btn-outline-secondary"><i class="bi bi-eye"></i></a>
}
</td>
</tr>
}
</tbody>
</table>
</div>
<div class="mobile-card-view">
<div class="mobile-card-list">
@foreach (var entry in Model) @foreach (var entry in Model)
{ {
var isBill = entry.EntryType == "Bill"; <tr class="@(entry.IsOverdue ? "table-warning" : "")">
var detailUrl = isBill <td>
? Url.Action("Details", "Bills", new { id = entry.Id }) @if (entry.EntryType == "Bill")
: Url.Action("Details", "Expenses", new { id = entry.Id }); {
<div class="mobile-data-card" onclick="window.location='@detailUrl'" <span class="badge bg-primary-subtle text-primary border border-primary-subtle">
style="@(entry.IsOverdue ? "border-left: 3px solid #f59e0b;" : "")"> <i class="bi bi-file-text me-1"></i>Bill
<div class="mobile-card-header"> </span>
<div class="mobile-card-icon" style="background: linear-gradient(135deg, @(isBill ? "#3b82f6 0%, #2563eb" : "#6b7280 0%, #4b5563") 100%);"> }
<i class="bi @(isBill ? "bi-file-text" : "bi-receipt")"></i> else
</div> {
<div class="mobile-card-title"> <span class="badge bg-secondary-subtle text-secondary border border-secondary-subtle">
<h6>@entry.Number</h6> <i class="bi bi-receipt me-1"></i>Expense
<small>@entry.VendorName</small> </span>
</div> }
<div class="ms-auto"> </td>
@if (isBill) <td>
{ @if (entry.EntryType == "Bill")
<span class="badge bg-primary-subtle text-primary border border-primary-subtle">Bill</span> {
} <a asp-controller="Bills" asp-action="Details" asp-route-id="@entry.Id"
else class="fw-medium text-decoration-none">@entry.Number</a>
{ }
<span class="badge bg-secondary-subtle text-secondary border border-secondary-subtle">Expense</span> else
} {
</div> <a asp-controller="Expenses" asp-action="Details" asp-route-id="@entry.Id"
</div> class="fw-medium text-decoration-none">@entry.Number</a>
<div class="mobile-card-body"> }
<div class="mobile-card-row"> </td>
<span class="mobile-card-label">Status</span> <td>@entry.VendorName</td>
<span class="mobile-card-value"><span class="badge bg-@entry.StatusColor">@entry.StatusLabel</span></span> <td class="text-muted small">
</div> @(entry.EntryType == "Bill" ? entry.Memo : entry.AccountName)
<div class="mobile-card-row"> @if (entry.HasReceipt)
<span class="mobile-card-label">Date</span> {
<span class="mobile-card-value">@entry.Date.ToString("MMM d, yyyy")</span> <i class="bi bi-paperclip ms-1" title="Has receipt"></i>
</div> }
</td>
<td>@entry.Date.ToString("MMM d, yyyy")</td>
<td>
@if (entry.DueDate.HasValue) @if (entry.DueDate.HasValue)
{ {
<div class="mobile-card-row"> <span class="@(entry.IsOverdue ? "text-danger fw-medium" : "")">
<span class="mobile-card-label">Due</span> @entry.DueDate.Value.ToString("MMM d, yyyy")
<span class="mobile-card-value @(entry.IsOverdue ? "text-danger fw-medium" : "")"> @if (entry.IsOverdue) { <i class="bi bi-exclamation-circle ms-1"></i> }
@entry.DueDate.Value.ToString("MMM d, yyyy") </span>
@if (entry.IsOverdue) { <i class="bi bi-exclamation-circle ms-1"></i> }
</span>
</div>
} }
<div class="mobile-card-row"> else if (entry.EntryType == "Expense")
<span class="mobile-card-label">Amount</span>
<span class="mobile-card-value fw-semibold">@entry.Total.ToString("C")</span>
</div>
@if (isBill)
{ {
<div class="mobile-card-row"> <span class="text-muted">&mdash;</span>
<span class="mobile-card-label">Balance Due</span>
<span class="mobile-card-value @(entry.BalanceDue > 0 ? "fw-semibold text-danger" : "text-muted")">
@entry.BalanceDue.ToString("C")
</span>
</div>
} }
@{ </td>
var memoText = isBill ? entry.Memo : entry.AccountName; <td><span class="badge bg-@entry.StatusColor">@entry.StatusLabel</span></td>
} <td class="text-end">@entry.Total.ToString("C")</td>
@if (!string.IsNullOrEmpty(memoText)) <td class="text-end fw-medium @(entry.BalanceDue > 0 ? "text-danger" : "text-muted")">
@Html.Raw(entry.EntryType == "Bill" ? entry.BalanceDue.ToString("C") : "&mdash;")
</td>
<td>
@if (entry.EntryType == "Bill")
{ {
<div class="mobile-card-row"> <a asp-controller="Bills" asp-action="Details" asp-route-id="@entry.Id"
<span class="mobile-card-label">@(isBill ? "Memo" : "Account")</span> class="btn btn-sm btn-outline-primary"><i class="bi bi-eye"></i></a>
<span class="mobile-card-value text-muted small">
@memoText
@if (entry.HasReceipt) { <i class="bi bi-paperclip ms-1" title="Has receipt"></i> }
</span>
</div>
} }
</div> else
<div class="mobile-card-footer"> {
<a href="@detailUrl" class="btn btn-sm @(isBill ? "btn-outline-primary" : "btn-outline-secondary")" <a asp-controller="Expenses" asp-action="Details" asp-route-id="@entry.Id"
onclick="event.stopPropagation()"> class="btn btn-sm btn-outline-secondary"><i class="bi bi-eye"></i></a>
<i class="bi bi-eye me-1"></i>View }
</a> </td>
</div> </tr>
</div>
} }
</div> </tbody>
</div> </table>
</div> </div>
</div> </div>
} }
@@ -41,6 +41,7 @@
@if (Model.AllowOnlinePayments) @if (Model.AllowOnlinePayments)
{ {
<option value="online-payments">Online Payments</option> <option value="online-payments">Online Payments</option>
<option value="card-readers">Card Readers</option>
} }
<option value="kiosk">Kiosk</option> <option value="kiosk">Kiosk</option>
<option value="timeclock">Timeclock</option> <option value="timeclock">Timeclock</option>
@@ -113,6 +114,11 @@
<i class="bi bi-credit-card"></i> Online Payments <i class="bi bi-credit-card"></i> Online Payments
</button> </button>
</li> </li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="card-readers-tab" data-bs-toggle="tab" data-bs-target="#card-readers" type="button" role="tab">
<i class="bi bi-credit-card-2-front"></i> Card Readers
</button>
</li>
} }
<li class="nav-item" role="presentation"> <li class="nav-item" role="presentation">
<button class="nav-link" id="kiosk-tab" data-bs-toggle="tab" data-bs-target="#kiosk" type="button" role="tab"> <button class="nav-link" id="kiosk-tab" data-bs-toggle="tab" data-bs-target="#kiosk" type="button" role="tab">
@@ -2018,6 +2024,102 @@
</div> </div>
</div> </div>
</div> </div>
<!-- Card Readers (Stripe Terminal) Tab -->
<div class="tab-pane fade" id="card-readers" role="tabpanel">
<div class="card mt-3">
<div class="card-header">
<h5 class="mb-0"><i class="bi bi-credit-card-2-front me-2"></i>Card Readers (In-Person Payments)</h5>
</div>
<div class="card-body">
@if (Model.StripeConnectStatus != PowderCoating.Core.Enums.StripeConnectStatus.Active)
{
<div class="alert alert-warning alert-permanent d-flex align-items-center gap-3 mb-0">
<i class="bi bi-exclamation-triangle-fill fs-4"></i>
<div>
<strong>Connect Stripe first.</strong>
<p class="mb-0 small">Card readers run on your connected Stripe account. Connect it on the
<strong>Online Payments</strong> tab, then come back to register a WisePOS&nbsp;E reader.</p>
</div>
</div>
}
else
{
<p class="text-muted small">
Take in-person card payments on a Stripe Terminal <strong>WisePOS&nbsp;E</strong> reader, billed
straight to your connected Stripe account. Register a reader below, then use
<strong>Take Card Payment</strong> on any invoice.
</p>
@* ── Register a reader ── *@
<h6 class="fw-semibold mb-2">Register a reader</h6>
<div class="row g-2 align-items-end mb-2">
<div class="col-sm-4">
<label class="form-label small mb-1">Registration code</label>
<input type="text" id="readerRegCode" class="form-control" placeholder="e.g. quick-brown-fox" />
</div>
<div class="col-sm-4">
<label class="form-label small mb-1">Label</label>
<input type="text" id="readerLabel" class="form-control" placeholder="e.g. Front Counter" />
</div>
<div class="col-sm-4">
<button type="button" class="btn btn-primary" id="registerReaderBtn">
<i class="bi bi-plus-circle me-1"></i>Add Reader
</button>
</div>
</div>
<p class="text-muted small">
On the reader, open <strong>Settings → Generate registration code</strong> to get the three-word code.
@if ((bool)(ViewBag.TerminalTestMode ?? false))
{
<span>In test mode, use code <code>simulated-wpe</code> to register a simulated reader.</span>
}
</p>
<div id="readerActionResult"></div>
@* ── Registered readers ── *@
<h6 class="fw-semibold mt-4 mb-2">Your readers</h6>
<div class="table-responsive">
<table class="table table-sm align-middle">
<thead>
<tr>
<th>Label</th>
<th>Device</th>
<th>Serial</th>
<th>Status</th>
<th class="text-end">Actions</th>
</tr>
</thead>
<tbody id="readersTableBody">
<tr><td colspan="5" class="text-muted small">Loading…</td></tr>
</tbody>
</table>
</div>
@* ── In-person surcharge (default off, compliance) ── *@
<fieldset class="mt-4">
<h6 class="fw-semibold mb-2">In-Person Surcharge</h6>
<div class="alert alert-warning alert-permanent small">
<i class="bi bi-exclamation-triangle me-1"></i>
In-person card surcharging is regulated <strong>differently</strong> than online and is
<strong>prohibited in some states</strong>. Leave this off unless you have confirmed it is allowed
where you operate. When enabled, the same fee configured on the Online Payments tab is applied.
</div>
<div class="form-check form-switch mb-3">
<input class="form-check-input" type="checkbox" id="terminalSurchargeEnabled"
@((bool)(ViewBag.TerminalSurchargeEnabled ?? false) ? "checked" : "") />
<label class="form-check-label" for="terminalSurchargeEnabled">
Apply my online surcharge to in-person card payments
</label>
</div>
<button type="button" class="btn btn-primary" id="saveTerminalSettingsBtn">
<i class="bi bi-floppy me-1"></i>Save Reader Settings
</button>
</fieldset>
}
</div>
</div>
</div>
} }
<!-- Kiosk Tab --> <!-- Kiosk Tab -->
@@ -3382,7 +3484,7 @@
document.getElementById('ovenCalcToggle').addEventListener('click', function (e) { document.getElementById('ovenCalcToggle').addEventListener('click', function (e) {
e.preventDefault(); e.preventDefault();
const hidden = _calcPanel.classList.toggle('d-none'); const hidden = _calcPanel.classList.toggle('d-none');
if (!hidden) { _calcW.value = ''; _calcD.value = ''; _calcH.value = ''; _calcResult.innerHTML = '&mdash;'; _calcApply.disabled = true; _calcW.focus(); } if (!hidden) { _calcW.value = ''; _calcD.value = ''; _calcH.value = ''; _calcResult.textContent = '&mdash;'; _calcApply.disabled = true; _calcW.focus(); }
}); });
function _updateCalc() { function _updateCalc() {
@@ -3402,7 +3504,7 @@
_calcApply.disabled = false; _calcApply.disabled = false;
_calcApply.dataset.val = val; _calcApply.dataset.val = val;
} else { } else {
_calcResult.innerHTML = '&mdash;'; _calcResult.textContent = '&mdash;';
_calcApply.disabled = true; _calcApply.disabled = true;
} }
} }
@@ -3420,7 +3522,7 @@
document.getElementById('ovenModal').addEventListener('hidden.bs.modal', function () { document.getElementById('ovenModal').addEventListener('hidden.bs.modal', function () {
_calcPanel.classList.add('d-none'); _calcPanel.classList.add('d-none');
_calcW.value = ''; _calcD.value = ''; _calcH.value = ''; _calcW.value = ''; _calcD.value = ''; _calcH.value = '';
_calcResult.innerHTML = '&mdash;'; _calcApply.disabled = true; _calcResult.textContent = '&mdash;'; _calcApply.disabled = true;
}); });
// ───────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────
@@ -3843,6 +3945,10 @@
} }
})(); })();
</script> </script>
@if (Model.AllowOnlinePayments && Model.StripeConnectStatus == PowderCoating.Core.Enums.StripeConnectStatus.Active)
{
<script src="~/js/terminal-readers.js" asp-append-version="true"></script>
}
<script src="~/js/company-settings-custom-formulas.js" asp-append-version="true"></script> <script src="~/js/company-settings-custom-formulas.js" asp-append-version="true"></script>
<script> <script>
// Load formula templates when the tab is first shown; auto-show walkthrough if no templates yet // Load formula templates when the tab is first shown; auto-show walkthrough if no templates yet
@@ -360,6 +360,33 @@
The link is unique to each invoice and does not expire as long as the invoice remains unpaid. The link is unique to each invoice and does not expire as long as the invoice remains unpaid.
Voided invoices do not generate payment links. Voided invoices do not generate payment links.
</p> </p>
<h3 id="card-readers" class="h6 fw-semibold mt-4 mb-2">In-Person Card Payments (WisePOS&nbsp;E)</h3>
<p>
Take a card payment in person against an invoice using a Stripe Terminal
<strong>WisePOS&nbsp;E</strong> card reader. This is included with the same plan that allows
online payments and runs on the same connected Stripe account &mdash; no separate merchant setup.
</p>
<ul class="mb-3">
<li class="mb-2"><strong>One-time setup:</strong> go to <strong>Settings &rsaquo; Card Readers</strong>
(the tab appears once Stripe is connected). On the reader, open
<strong>Settings &rsaquo; Generate registration code</strong> to get a three-word code, enter it
with a label such as &ldquo;Front Counter,&rdquo; and click <strong>Add Reader</strong>.</li>
<li class="mb-2"><strong>Taking a payment:</strong> on an invoice with a balance due, click
<strong>Take Card Payment</strong>, choose the reader, confirm the amount, and click
<strong>Send to Reader</strong>. The reader prompts the customer to tap, insert, or swipe; the
screen shows live progress and refreshes the invoice once the payment is approved.</li>
<li class="mb-2">The payment is recorded automatically with the method <strong>Card Reader</strong>,
posts to your books, and advances the invoice to Paid or Partially Paid. Partial payments are supported.</li>
</ul>
<div class="alert alert-permanent alert-warning d-flex gap-2 mb-0" role="alert">
<i class="bi bi-exclamation-triangle flex-shrink-0 mt-1"></i>
<div>
<strong>In-person surcharging is off by default.</strong> It can be enabled on the Card Readers tab,
but in-person card surcharging is regulated differently than online payments and is prohibited in some
states &mdash; only turn it on after confirming the rules where you operate.
</div>
</div>
</section> </section>
<section id="payment-reminders" class="mb-5"> <section id="payment-reminders" class="mb-5">
@@ -415,6 +442,7 @@
<a class="nav-link py-1 px-3 small text-body" href="#deposits">Deposits</a> <a class="nav-link py-1 px-3 small text-body" href="#deposits">Deposits</a>
<a class="nav-link py-1 px-3 small text-body" href="#gift-certificates">Gift Certificates</a> <a class="nav-link py-1 px-3 small text-body" href="#gift-certificates">Gift Certificates</a>
<a class="nav-link py-1 px-3 small text-body" href="#online-payments">Online Payments</a> <a class="nav-link py-1 px-3 small text-body" href="#online-payments">Online Payments</a>
<a class="nav-link py-1 px-3 small text-body" href="#card-readers">In-Person Card Payments</a>
<a class="nav-link py-1 px-3 small text-body" href="#payment-reminders">Payment Reminders</a> <a class="nav-link py-1 px-3 small text-body" href="#payment-reminders">Payment Reminders</a>
</nav> </nav>
</div> </div>
@@ -644,6 +644,12 @@
<button type="button" class="btn btn-success" data-bs-toggle="modal" data-bs-target="#recordPaymentModal"> <button type="button" class="btn btn-success" data-bs-toggle="modal" data-bs-target="#recordPaymentModal">
<i class="bi bi-cash me-2"></i>Record Payment <i class="bi bi-cash me-2"></i>Record Payment
</button> </button>
@if ((bool)(ViewBag.TerminalPaymentsEnabled ?? false))
{
<button type="button" class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#cardReaderModal">
<i class="bi bi-credit-card-2-front me-2"></i>Take Card Payment
</button>
}
} }
<a asp-action="DownloadPdf" asp-route-id="@Model.Id" asp-route-inline="true" <a asp-action="DownloadPdf" asp-route-id="@Model.Id" asp-route-inline="true"
class="btn btn-outline-secondary" target="_blank" rel="noopener"> class="btn btn-outline-secondary" target="_blank" rel="noopener">
@@ -1029,12 +1035,6 @@
<label class="form-label">Notes</label> <label class="form-label">Notes</label>
<textarea name="Notes" class="form-control" rows="2" placeholder="Optional"></textarea> <textarea name="Notes" class="form-control" rows="2" placeholder="Optional"></textarea>
</div> </div>
<div class="form-check">
<input class="form-check-input" type="checkbox" name="SuppressNotification" value="true" id="suppressNotificationCheck" />
<label class="form-check-label text-muted small" for="suppressNotificationCheck">
Don&rsquo;t notify customer
</label>
</div>
</div> </div>
<div class="modal-footer"> <div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button> <button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
@@ -1048,6 +1048,67 @@
</div> </div>
} }
@if (canPay && (bool)(ViewBag.TerminalPaymentsEnabled ?? false))
{
var terminalReaders = ViewBag.TerminalReaders as IEnumerable<SelectListItem> ?? Enumerable.Empty<SelectListItem>();
<!-- Take Card Payment (Stripe Terminal) Modal -->
<div class="modal fade" id="cardReaderModal" tabindex="-1"
data-invoice-id="@Model.Id"
data-balance-due="@Model.BalanceDue.ToString("F2")"
data-test-mode="@((bool)(ViewBag.TerminalTestMode ?? false) ? "true" : "false")">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title"><i class="bi bi-credit-card-2-front me-2"></i>Take Card Payment</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
@* Setup view: choose reader + amount, then send to the reader *@
<div id="cardReaderSetup">
<div class="mb-3">
<label class="form-label fw-semibold" for="cardReaderSelect">Card Reader</label>
<select id="cardReaderSelect" class="form-select">
@foreach (var r in terminalReaders)
{
<option value="@r.Value">@r.Text</option>
}
</select>
</div>
<div class="mb-3">
<label class="form-label fw-semibold" for="cardReaderAmount">Amount <span class="text-danger">*</span></label>
<div class="input-group">
<span class="input-group-text">$</span>
<input type="number" id="cardReaderAmount" class="form-control" step="0.01" min="0.01"
max="@Model.BalanceDue" value="@Model.BalanceDue.ToString("F2")" />
</div>
<div class="form-text">Balance due: @Model.BalanceDue.ToString("C")</div>
</div>
</div>
@* Status view: live progress while the customer presents their card *@
<div id="cardReaderStatus" class="text-center py-4 d-none">
<div id="cardReaderSpinner" class="spinner-border text-primary mb-3" role="status"></div>
<div id="cardReaderStatusText" class="fw-semibold"></div>
<div id="cardReaderStatusSub" class="text-muted small mt-1"></div>
</div>
</div>
<div class="modal-footer">
<button type="button" id="cardReaderCancelBtn" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
@if ((bool)(ViewBag.TerminalTestMode ?? false))
{
<button type="button" id="cardReaderSimulateBtn" class="btn btn-outline-info d-none" title="Test mode only">
<i class="bi bi-magic me-1"></i>Simulate Tap
</button>
}
<button type="button" id="cardReaderProcessBtn" class="btn btn-primary">
<i class="bi bi-send me-2"></i>Send to Reader
</button>
</div>
</div>
</div>
</div>
}
<!-- Edit Payment Modal --> <!-- Edit Payment Modal -->
@if (!isVoided) @if (!isVoided)
{ {
@@ -1537,6 +1598,18 @@
} }
}; };
</script> </script>
@if (canPay && (bool)(ViewBag.TerminalPaymentsEnabled ?? false))
{
<script>
window.terminalPayment = {
processUrl: '@Url.Action("ProcessPayment", "Terminal")',
statusUrl: '@Url.Action("PaymentStatus", "Terminal")',
cancelUrl: '@Url.Action("CancelPayment", "Terminal")',
simulateUrl: '@Url.Action("SimulateTap", "Terminal")'
};
</script>
<script src="~/js/terminal-payment.js" asp-append-version="true"></script>
}
<script> <script>
function submitSendInvoice(sendEmail, sendSms) { function submitSendInvoice(sendEmail, sendSms) {
document.getElementById('sendInvoiceSendEmail').value = sendEmail ? 'true' : 'false'; document.getElementById('sendInvoiceSendEmail').value = sendEmail ? 'true' : 'false';
@@ -11,13 +11,8 @@
ViewData["PageHelpContent"] = "Invoices are created from completed jobs and sent to the customer for payment. Lifecycle: Draft (editable) → Sent (locked, awaiting payment) → Partially Paid / Paid. Overdue = past due date with a balance still owed. Outstanding shows the total A/R balance across all unpaid invoices currently on screen. Use Void to cancel without deleting history."; ViewData["PageHelpContent"] = "Invoices are created from completed jobs and sent to the customer for payment. Lifecycle: Draft (editable) → Sent (locked, awaiting payment) → Partially Paid / Paid. Overdue = past due date with a balance still owed. Outstanding shows the total A/R balance across all unpaid invoices currently on screen. Use Void to cancel without deleting history.";
var searchTerm = ViewBag.SearchTerm as string; var searchTerm = ViewBag.SearchTerm as string;
var statusFilter = ViewBag.StatusFilter as InvoiceStatus?; var statusFilter = ViewBag.StatusFilter as InvoiceStatus?;
var statusGroup = ViewBag.StatusGroup as string;
var outstandingOnly = (bool)(ViewBag.OutstandingOnly ?? false); var outstandingOnly = (bool)(ViewBag.OutstandingOnly ?? false);
var thisMonthOnly = (bool)(ViewBag.ThisMonthOnly ?? false); var thisMonthOnly = (bool)(ViewBag.ThisMonthOnly ?? false);
var unpaidCount = (int)(ViewBag.UnpaidCount ?? 0);
var partialCount = (int)(ViewBag.PartialCount ?? 0);
var paidCount = (int)(ViewBag.PaidCount ?? 0);
var allCount = (int)(ViewBag.AllCount ?? 0);
} }
@{ @{
@@ -57,77 +52,52 @@
<div class="card border-0 shadow-sm"> <div class="card border-0 shadow-sm">
<div class="card-header border-0 py-3"> <div class="card-header border-0 py-3">
<div class="d-flex flex-column gap-2"> <div class="d-flex flex-column flex-lg-row justify-content-between align-items-start align-items-lg-center gap-3">
<!-- Row 1: search + dropdown + actions --> <form asp-action="Index" method="get" class="d-flex flex-nowrap gap-2 align-items-center">
<div class="d-flex flex-wrap gap-2 justify-content-between align-items-center"> <input type="hidden" name="sortColumn" value="@ViewBag.SortColumn" />
<form asp-action="Index" method="get" class="d-flex flex-nowrap gap-2 align-items-center flex-grow-1" style="max-width:560px;"> <input type="hidden" name="sortDirection" value="@ViewBag.SortDirection" />
<input type="hidden" name="sortColumn" value="@ViewBag.SortColumn" /> <input type="hidden" name="pageSize" value="@Model.PageSize" />
<input type="hidden" name="sortDirection" value="@ViewBag.SortDirection" /> @if (outstandingOnly) { <input type="hidden" name="outstandingOnly" value="true" /> }
<input type="hidden" name="pageSize" value="@Model.PageSize" /> @if (thisMonthOnly) { <input type="hidden" name="thisMonthOnly" value="true" /> }
@if (outstandingOnly) { <input type="hidden" name="outstandingOnly" value="true" /> } <div class="input-group" style="max-width:280px; min-width:180px;">
@if (thisMonthOnly) { <input type="hidden" name="thisMonthOnly" value="true" /> } <span class="input-group-text border-end-0"><i class="bi bi-search text-muted"></i></span>
<div class="input-group" style="min-width:180px;"> <input type="text" name="searchTerm" class="form-control border-start-0"
<span class="input-group-text border-end-0"><i class="bi bi-search text-muted"></i></span> placeholder="Search invoices..." value="@searchTerm">
<input type="text" name="searchTerm" class="form-control border-start-0" </div>
placeholder="Search invoices&hellip;" value="@searchTerm"> <select class="form-select" name="statusFilter" style="width:auto;">
</div> <option value="">All Statuses</option>
<select class="form-select" name="statusFilter" style="width:auto;" onchange="this.form.submit()"> @foreach (InvoiceStatus s in Enum.GetValues(typeof(InvoiceStatus)))
<option value="">All Statuses</option>
@foreach (InvoiceStatus s in Enum.GetValues(typeof(InvoiceStatus)))
{
<option value="@((int)s)" selected="@(statusFilter == s)">@InvoicesController.GetStatusDisplay(s)</option>
}
</select>
<button type="submit" class="btn btn-primary"><i class="bi bi-search"></i></button>
@if (!string.IsNullOrEmpty(searchTerm) || statusFilter.HasValue || outstandingOnly || thisMonthOnly || !string.IsNullOrEmpty(statusGroup))
{ {
<a asp-action="Index" asp-route-statusGroup="unpaid" class="btn btn-outline-secondary text-nowrap"><i class="bi bi-x-lg"></i></a> <option value="@((int)s)" selected="@(statusFilter == s)">@InvoicesController.GetStatusDisplay(s)</option>
} }
</form> </select>
<a asp-action="Create" class="btn btn-primary text-nowrap"> <button type="submit" class="btn btn-primary"><i class="bi bi-search"></i></button>
<i class="bi bi-plus-circle me-2"></i>New Invoice @if (!string.IsNullOrEmpty(searchTerm) || statusFilter.HasValue || outstandingOnly || thisMonthOnly)
</a> {
</div> <a asp-action="Index" class="btn btn-outline-secondary">Clear</a>
<!-- Row 2: status-group pills --> }
<div class="pcl-pill-group"> @if (outstandingOnly)
<a href="@Url.Action("Index", new { statusGroup = "all" })" class="pcl-pill @(statusGroup == "all" ? "active" : "")"> {
All <span class="pcl-pill-count">@allCount</span>
</a>
<a href="@Url.Action("Index", new { statusGroup = "unpaid" })" class="pcl-pill @(statusGroup == "unpaid" ? "active" : "")">
Unpaid <span class="pcl-pill-count">@unpaidCount</span>
</a>
<a href="@Url.Action("Index", new { statusGroup = "partial" })" class="pcl-pill @(statusGroup == "partial" ? "active" : "")">
Partial <span class="pcl-pill-count">@partialCount</span>
</a>
<a href="@Url.Action("Index", new { statusGroup = "paid" })" class="pcl-pill @(statusGroup == "paid" ? "active" : "")">
Paid <span class="pcl-pill-count">@paidCount</span>
</a>
</div>
<!-- Legacy filter badges (outstanding A/R, this-month) -->
@if (outstandingOnly)
{
<div>
<span class="badge bg-info text-dark fs-6 fw-normal"> <span class="badge bg-info text-dark fs-6 fw-normal">
<i class="bi bi-funnel-fill me-1"></i>Outstanding A/R <i class="bi bi-funnel-fill me-1"></i>Outstanding A/R
</span> </span>
</div> }
} @if (thisMonthOnly && statusFilter == InvoiceStatus.Paid)
@if (thisMonthOnly && statusFilter == InvoiceStatus.Paid) {
{
<div>
<span class="badge bg-success fs-6 fw-normal"> <span class="badge bg-success fs-6 fw-normal">
<i class="bi bi-funnel-fill me-1"></i>Paid &mdash; @DateTime.Now.ToString("MMMM yyyy") <i class="bi bi-funnel-fill me-1"></i>Paid &mdash; @DateTime.Now.ToString("MMMM yyyy")
</span> </span>
</div> }
} else if (thisMonthOnly)
else if (thisMonthOnly) {
{
<div>
<span class="badge bg-info text-dark fs-6 fw-normal"> <span class="badge bg-info text-dark fs-6 fw-normal">
<i class="bi bi-funnel-fill me-1"></i>@DateTime.Now.ToString("MMMM yyyy") <i class="bi bi-funnel-fill me-1"></i>@DateTime.Now.ToString("MMMM yyyy")
</span> </span>
</div> }
} </form>
<a asp-action="Create" class="btn btn-primary text-nowrap">
<i class="bi bi-plus-circle me-2"></i>New Invoice
</a>
</div> </div>
</div> </div>
<div class="card-body p-0"> <div class="card-body p-0">
@@ -2936,12 +2936,8 @@
profitEl.className = profit >= 0 ? 'text-success' : 'text-danger'; profitEl.className = profit >= 0 ? 'text-success' : 'text-danger';
document.getElementById('costingMargin').textContent = `${d.grossMargin}%`; document.getElementById('costingMargin').textContent = `${d.grossMargin}%`;
const quotedMarginEl = document.getElementById('costingQuotedMargin'); document.getElementById('costingQuotedMargin').textContent =
if (d.quotedPrice > 0) { d.quotedPrice > 0 ? `${d.quotedMargin}% (quoted ${fmt(d.quotedPrice)})` : '&mdash;';
quotedMarginEl.textContent = `${d.quotedMargin}% (quoted ${fmt(d.quotedPrice)})`;
} else {
quotedMarginEl.innerHTML = '&mdash;';
}
// Powder detail lines // Powder detail lines
const pBody = document.getElementById('powderLines'); const pBody = document.getElementById('powderLines');
@@ -168,7 +168,7 @@
} catch (e) { } catch (e) {
document.getElementById('loadingState').classList.add('d-none'); document.getElementById('loadingState').classList.add('d-none');
document.getElementById('errorMessage').innerHTML = 'Network error &mdash; please try again.'; document.getElementById('errorMessage').textContent = 'Network error &mdash; please try again.';
document.getElementById('errorState').classList.remove('d-none'); document.getElementById('errorState').classList.remove('d-none');
} }
} }
@@ -225,7 +225,7 @@
} catch (e) { } catch (e) {
document.getElementById('loadingState').classList.add('d-none'); document.getElementById('loadingState').classList.add('d-none');
document.getElementById('errorMessage').innerHTML = 'Network error &mdash; please try again.'; document.getElementById('errorMessage').textContent = 'Network error &mdash; please try again.';
document.getElementById('errorState').classList.remove('d-none'); document.getElementById('errorState').classList.remove('d-none');
} }
} }
@@ -91,23 +91,13 @@ document.addEventListener('DOMContentLoaded', () => {
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 // Save scroll position before the form causes a full-page reload so we can
// restore it after the server redirects back to this page on a validation error. // restore it after the server redirects back to this page. Key is path-specific
// Key is path-specific; cleared on pagehide unless we're leaving via a submit so // so navigating away and back doesn't restore a stale position.
// a fresh navigation to this page never restores a stale position.
const scrollKey = 'wizardScrollY:' + location.pathname; const scrollKey = 'wizardScrollY:' + location.pathname;
let wizardSubmitting = false;
ownerForm.addEventListener('submit', () => { ownerForm.addEventListener('submit', () => {
wizardSubmitting = true;
sessionStorage.setItem(scrollKey, String(Math.round(window.scrollY))); sessionStorage.setItem(scrollKey, String(Math.round(window.scrollY)));
}, { capture: true }); }, { capture: true });
// If the page unloads for any reason other than our own form submit (e.g. the
// user clicks a nav link or the server redirects to a success page), discard the
// saved position so it doesn't fire on the next fresh visit.
window.addEventListener('pagehide', () => {
if (!wizardSubmitting) sessionStorage.removeItem(scrollKey);
});
// Restore on load — fire after layout is painted so scrollTo lands correctly. // Restore on load — fire after layout is painted so scrollTo lands correctly.
const savedY = sessionStorage.getItem(scrollKey); const savedY = sessionStorage.getItem(scrollKey);
if (savedY !== null) { if (savedY !== null) {
@@ -0,0 +1,209 @@
// terminal-payment.js
// Drives the "Take Card Payment" modal on the invoice Details page: pushes a card_present
// PaymentIntent to a Stripe Terminal reader (WisePOS E), polls the reader's action status for live
// feedback, and reloads the page once the webhook has recorded the payment. The webhook — not this
// script — is the source of truth for the ledger; here we only report progress.
(function () {
'use strict';
var cfg = window.terminalPayment;
var modalEl = document.getElementById('cardReaderModal');
if (!cfg || !modalEl) return;
var invoiceId = modalEl.dataset.invoiceId;
var testMode = modalEl.dataset.testMode === 'true';
var setupView = document.getElementById('cardReaderSetup');
var statusView = document.getElementById('cardReaderStatus');
var statusText = document.getElementById('cardReaderStatusText');
var statusSub = document.getElementById('cardReaderStatusSub');
var spinner = document.getElementById('cardReaderSpinner');
var readerSelect = document.getElementById('cardReaderSelect');
var amountInput = document.getElementById('cardReaderAmount');
var processBtn = document.getElementById('cardReaderProcessBtn');
var cancelBtn = document.getElementById('cardReaderCancelBtn');
var simulateBtn = document.getElementById('cardReaderSimulateBtn');
var POLL_MS = 2500;
var TIMEOUT_MS = 90000;
var pollTimer = null;
var timeoutTimer = null;
var currentPI = null;
var currentReaderId = null;
function csrf() {
var el = document.querySelector('input[name="__RequestVerificationToken"]');
return el ? el.value : '';
}
function post(url, data) {
return fetch(url, {
method: 'POST',
headers: {
'RequestVerificationToken': csrf(),
'Content-Type': 'application/x-www-form-urlencoded'
},
body: new URLSearchParams(data)
}).then(function (r) { return r.json(); });
}
function getJson(url) {
return fetch(url, { headers: { 'RequestVerificationToken': csrf() } })
.then(function (r) { return r.json(); });
}
function clearTimers() {
if (pollTimer) { clearTimeout(pollTimer); pollTimer = null; }
if (timeoutTimer) { clearTimeout(timeoutTimer); timeoutTimer = null; }
}
function showStatus(text, sub, busy) {
setupView.classList.add('d-none');
statusView.classList.remove('d-none');
statusText.textContent = text;
statusSub.textContent = sub || '';
spinner.classList.toggle('d-none', !busy);
processBtn.classList.add('d-none');
if (simulateBtn) simulateBtn.classList.toggle('d-none', !(testMode && busy));
}
function backToSetup() {
clearTimers();
currentPI = null;
statusView.classList.add('d-none');
setupView.classList.remove('d-none');
processBtn.classList.remove('d-none');
processBtn.disabled = false;
processBtn.innerHTML = '<i class="bi bi-send me-2"></i>Send to Reader';
if (simulateBtn) simulateBtn.classList.add('d-none');
cancelBtn.textContent = 'Cancel';
}
function fail(message) {
clearTimers();
spinner.classList.add('d-none');
statusText.textContent = 'Payment did not complete';
statusSub.textContent = message || 'Please try again.';
if (simulateBtn) simulateBtn.classList.add('d-none');
// Offer a retry by returning to the setup view via the footer button.
processBtn.classList.remove('d-none');
processBtn.disabled = false;
processBtn.innerHTML = '<i class="bi bi-arrow-repeat me-2"></i>Try Again';
}
function succeed() {
clearTimers();
spinner.classList.add('d-none');
statusText.textContent = 'Approved ✓';
statusSub.textContent = 'Updating invoice…';
// The webhook has recorded the payment; reload so the new payment row + balance show.
setTimeout(function () { window.location.reload(); }, 900);
}
function poll() {
if (!currentPI) return;
var url = cfg.statusUrl + '?readerId=' + encodeURIComponent(currentReaderId) +
'&paymentIntentId=' + encodeURIComponent(currentPI);
getJson(url).then(function (res) {
if (res.webhookRecorded) { succeed(); return; }
if (res.actionStatus === 'failed') {
fail(res.failureMessage || 'The card was declined or the payment was cancelled.');
return;
}
// still in_progress (or webhook not landed yet) — keep polling
pollTimer = setTimeout(poll, POLL_MS);
}).catch(function () {
// Transient error — keep polling until the overall timeout fires.
pollTimer = setTimeout(poll, POLL_MS);
});
}
function process() {
var amount = parseFloat(amountInput.value);
var balance = parseFloat(modalEl.dataset.balanceDue);
if (isNaN(amount) || amount <= 0 || amount > balance + 0.0001) {
amountInput.classList.add('is-invalid');
return;
}
amountInput.classList.remove('is-invalid');
currentReaderId = readerSelect.value;
processBtn.disabled = true;
processBtn.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>Sending…';
post(cfg.processUrl, {
invoiceId: invoiceId,
readerId: currentReaderId,
amount: amount.toFixed(2)
}).then(function (res) {
if (!res.success) {
backToSetup();
showInlineError(res.error || 'Could not start the payment.');
return;
}
currentPI = res.paymentIntentId;
cancelBtn.textContent = 'Cancel Payment';
showStatus('Follow the prompts on the reader', 'Ask the customer to tap, insert, or swipe their card.', true);
pollTimer = setTimeout(poll, POLL_MS);
timeoutTimer = setTimeout(function () {
fail('This took longer than expected. Check the reader, then try again.');
}, TIMEOUT_MS);
});
}
function showInlineError(message) {
var existing = document.getElementById('cardReaderInlineError');
if (!existing) {
existing = document.createElement('div');
existing.id = 'cardReaderInlineError';
existing.className = 'alert alert-danger mt-2 mb-0';
setupView.appendChild(existing);
}
existing.textContent = message;
}
function cancelOnReader() {
if (!currentReaderId) return;
post(cfg.cancelUrl, { readerId: currentReaderId });
}
// Process / Try Again button.
processBtn.addEventListener('click', function () {
if (currentPI === null && statusView.classList.contains('d-none')) {
process();
} else {
// "Try Again" after a failure — reset to setup, the next click processes.
backToSetup();
}
});
// Cancel button: if a payment is in flight, cancel it on the reader before the modal closes.
cancelBtn.addEventListener('click', function () {
if (currentPI) cancelOnReader();
});
if (simulateBtn) {
simulateBtn.addEventListener('click', function () {
simulateBtn.disabled = true;
post(cfg.simulateUrl, { readerId: currentReaderId }).then(function () {
simulateBtn.disabled = false;
});
});
}
// Reset state whenever the modal is reopened.
modalEl.addEventListener('show.bs.modal', function () {
clearTimers();
currentPI = null;
currentReaderId = null;
var err = document.getElementById('cardReaderInlineError');
if (err) err.remove();
backToSetup();
amountInput.value = parseFloat(modalEl.dataset.balanceDue).toFixed(2);
});
// If the clerk closes the modal mid-payment, stop polling (the webhook still records it).
modalEl.addEventListener('hidden.bs.modal', function () {
clearTimers();
});
})();
@@ -0,0 +1,129 @@
// terminal-readers.js
// Powers the Company Settings "Card Readers" tab: registering, listing, and deactivating Stripe
// Terminal readers, plus saving the in-person surcharge toggle. Loaded only when the company has an
// active Stripe Connect account.
(function () {
'use strict';
function token() {
var el = document.querySelector('input[name="__RequestVerificationToken"]');
return el ? el.value : '';
}
function notifyOk(msg) {
if (typeof showSuccess === 'function') showSuccess(msg); else console.log(msg);
}
function notifyErr(msg) {
if (typeof showError === 'function') showError(msg); else console.error(msg);
}
function post(url, data) {
return fetch(url, {
method: 'POST',
headers: {
'RequestVerificationToken': token(),
'Content-Type': 'application/x-www-form-urlencoded'
},
body: new URLSearchParams(data)
}).then(function (r) { return r.json(); });
}
var tableBody = document.getElementById('readersTableBody');
var registerBtn = document.getElementById('registerReaderBtn');
var saveSettingsBtn = document.getElementById('saveTerminalSettingsBtn');
var loaded = false;
function escapeHtml(s) {
return (s || '').replace(/[&<>"']/g, function (c) {
return { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c];
});
}
function renderReaders(readers) {
if (!readers || readers.length === 0) {
tableBody.innerHTML = '<tr><td colspan="5" class="text-muted small">No readers registered yet.</td></tr>';
return;
}
tableBody.innerHTML = readers.map(function (r) {
var net = r.networkStatus
? '<span class="badge bg-' + (r.networkStatus === 'online' ? 'success' : 'secondary') + '">' + escapeHtml(r.networkStatus) + '</span>'
: '<span class="text-muted small">&mdash;</span>';
return '<tr>' +
'<td>' + escapeHtml(r.label) + '</td>' +
'<td class="small text-muted">' + escapeHtml(r.deviceType) + '</td>' +
'<td class="small text-muted">' + escapeHtml(r.serialNumber || '&mdash;') + '</td>' +
'<td>' + net + '</td>' +
'<td class="text-end"><button type="button" class="btn btn-outline-danger btn-sm" data-reader-id="' + r.id + '">' +
'<i class="bi bi-trash"></i></button></td>' +
'</tr>';
}).join('');
}
function loadReaders() {
fetch('/Terminal/ListReaders', { headers: { 'RequestVerificationToken': token() } })
.then(function (r) { return r.json(); })
.then(function (res) {
if (res.success) renderReaders(res.readers);
else tableBody.innerHTML = '<tr><td colspan="5" class="text-danger small">Could not load readers.</td></tr>';
})
.catch(function () {
tableBody.innerHTML = '<tr><td colspan="5" class="text-danger small">Could not load readers.</td></tr>';
});
}
if (registerBtn) {
registerBtn.addEventListener('click', function () {
var code = document.getElementById('readerRegCode').value.trim();
var label = document.getElementById('readerLabel').value.trim();
if (!code || !label) { notifyErr('Enter both a registration code and a label.'); return; }
registerBtn.disabled = true;
post('/Terminal/RegisterReader', { registrationCode: code, label: label }).then(function (res) {
registerBtn.disabled = false;
if (res.success) {
notifyOk('Reader registered.');
document.getElementById('readerRegCode').value = '';
document.getElementById('readerLabel').value = '';
loadReaders();
} else {
notifyErr(res.error || 'Could not register the reader.');
}
}).catch(function () {
registerBtn.disabled = false;
notifyErr('Could not register the reader.');
});
});
}
// Deactivate (event delegation on the table body).
if (tableBody) {
tableBody.addEventListener('click', function (e) {
var btn = e.target.closest('button[data-reader-id]');
if (!btn) return;
if (!confirm('Remove this reader? You can register it again later.')) return;
btn.disabled = true;
post('/Terminal/DeactivateReader', { id: btn.dataset.readerId }).then(function (res) {
if (res.success) { notifyOk('Reader removed.'); loadReaders(); }
else { btn.disabled = false; notifyErr(res.error || 'Could not remove the reader.'); }
});
});
}
if (saveSettingsBtn) {
saveSettingsBtn.addEventListener('click', function () {
var enabled = document.getElementById('terminalSurchargeEnabled').checked;
post('/Terminal/UpdateTerminalSettings', { surchargeEnabled: enabled }).then(function (res) {
if (res.success) notifyOk('Reader settings saved.');
else notifyErr(res.error || 'Could not save settings.');
});
});
}
// Lazy-load the readers list the first time the tab is shown.
var tabBtn = document.getElementById('card-readers-tab');
if (tabBtn) {
tabBtn.addEventListener('shown.bs.tab', function () {
if (!loaded) { loadReaders(); loaded = true; }
});
}
})();
@@ -123,6 +123,7 @@
var todayLine = result.dailyTotal.toFixed(2) + ' hrs today (' + result.segmentCount + (result.segmentCount === 1 ? ' segment' : ' segments') + ')'; var todayLine = result.dailyTotal.toFixed(2) + ' hrs today (' + result.segmentCount + (result.segmentCount === 1 ? ' segment' : ' segments') + ')';
document.getElementById('tc-confirm-icon').innerHTML = icon; document.getElementById('tc-confirm-icon').innerHTML = icon;
document.getElementById('tc-confirm-title').textContent = result.displayName + ' &mdash; ' + title;
document.getElementById('tc-confirm-title').innerHTML = escHtml(result.displayName) + ' &mdash; ' + title; document.getElementById('tc-confirm-title').innerHTML = escHtml(result.displayName) + ' &mdash; ' + title;
document.getElementById('tc-confirm-time').textContent = timeStr; document.getElementById('tc-confirm-time').textContent = timeStr;
document.getElementById('tc-confirm-today').textContent = todayLine; document.getElementById('tc-confirm-today').textContent = todayLine;
@@ -514,7 +514,8 @@ public class PricingStageFlowTests
CreateTenantContext().Object, CreateTenantContext().Object,
Mock.Of<INotificationService>(), Mock.Of<INotificationService>(),
Mock.Of<IAccountBalanceService>(), Mock.Of<IAccountBalanceService>(),
Mock.Of<ICompanyLogoService>()); Mock.Of<ICompanyLogoService>(),
new Microsoft.Extensions.Configuration.ConfigurationBuilder().Build());
var identity = new ClaimsIdentity([new Claim(ClaimTypes.Role, "SuperAdmin")], "Test"); var identity = new ClaimsIdentity([new Claim(ClaimTypes.Role, "SuperAdmin")], "Test");
var principal = new ClaimsPrincipal(identity); var principal = new ClaimsPrincipal(identity);
@@ -0,0 +1,232 @@
using System.Security.Claims;
using System.Text.Json;
using Microsoft.AspNetCore.Http;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
using Moq;
using PowderCoating.Application.Interfaces;
using PowderCoating.Core.Entities;
using PowderCoating.Core.Enums;
using PowderCoating.Core.Interfaces;
using PowderCoating.Infrastructure.Data;
using PowderCoating.Infrastructure.Repositories;
using PowderCoating.Web.Controllers;
namespace PowderCoating.UnitTests;
/// <summary>
/// Validation and surcharge-routing tests for the in-person Stripe Terminal payment flow.
/// The Stripe API itself is mocked via <see cref="IStripeConnectService"/>; these tests cover the
/// controller's guard rails and the TerminalSurchargeEnabled toggle, not Stripe behavior.
/// </summary>
public class TerminalControllerTests
{
private const int CompanyId = 1;
[Fact]
public async Task ProcessPayment_WhenAmountExceedsBalance_ReturnsError()
{
await using var context = CreateContext();
SeedCompany(context, connected: true);
SeedInvoice(context, total: 100m, amountPaid: 0m);
SeedReader(context);
await context.SaveChangesAsync();
var stripe = new Mock<IStripeConnectService>();
var controller = CreateController(context, stripe);
var result = await controller.ProcessPayment(invoiceId: 1, readerId: 1, amount: 250m);
using var doc = ParseJson(result);
Assert.False(doc.RootElement.GetProperty("success").GetBoolean());
stripe.Verify(s => s.ProcessInvoicePaymentOnReaderAsync(
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<decimal>(), It.IsAny<decimal>(),
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<int>()), Times.Never);
}
[Fact]
public async Task ProcessPayment_WhenInvoiceVoided_ReturnsError()
{
await using var context = CreateContext();
SeedCompany(context, connected: true);
SeedInvoice(context, total: 100m, amountPaid: 0m, status: InvoiceStatus.Voided);
SeedReader(context);
await context.SaveChangesAsync();
var stripe = new Mock<IStripeConnectService>();
var controller = CreateController(context, stripe);
var result = await controller.ProcessPayment(invoiceId: 1, readerId: 1, amount: 50m);
using var doc = ParseJson(result);
Assert.False(doc.RootElement.GetProperty("success").GetBoolean());
}
[Fact]
public async Task ProcessPayment_WhenStripeNotConnected_ReturnsError()
{
await using var context = CreateContext();
SeedCompany(context, connected: false);
SeedInvoice(context, total: 100m, amountPaid: 0m);
SeedReader(context);
await context.SaveChangesAsync();
var stripe = new Mock<IStripeConnectService>();
var controller = CreateController(context, stripe);
var result = await controller.ProcessPayment(invoiceId: 1, readerId: 1, amount: 50m);
using var doc = ParseJson(result);
Assert.False(doc.RootElement.GetProperty("success").GetBoolean());
}
[Fact]
public async Task ProcessPayment_WhenSurchargeDisabled_PassesZeroSurchargeAndStoresPaymentIntent()
{
await using var context = CreateContext();
SeedCompany(context, connected: true, surchargeEnabled: false, surchargePercent: 3m);
SeedInvoice(context, total: 100m, amountPaid: 0m);
SeedReader(context);
await context.SaveChangesAsync();
var stripe = new Mock<IStripeConnectService>();
stripe.Setup(s => s.ProcessInvoicePaymentOnReaderAsync(
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<decimal>(), It.IsAny<decimal>(),
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<int>()))
.ReturnsAsync((true, "pi_123", (string?)null));
var controller = CreateController(context, stripe);
var result = await controller.ProcessPayment(invoiceId: 1, readerId: 1, amount: 100m);
using var doc = ParseJson(result);
Assert.True(doc.RootElement.GetProperty("success").GetBoolean());
Assert.Equal("pi_123", doc.RootElement.GetProperty("paymentIntentId").GetString());
// Surcharge must be 0 when the toggle is off, even though the company has a 3% online fee.
stripe.Verify(s => s.ProcessInvoicePaymentOnReaderAsync(
"acct_test", "tmr_test", 100m, 0m, "usd", "INV-1", 1), Times.Once);
// The PaymentIntent id must be persisted on the invoice for the webhook idempotency guard.
var invoice = await context.Invoices.IgnoreQueryFilters().SingleAsync();
Assert.Equal("pi_123", invoice.StripePaymentIntentId);
Assert.Equal(OnlinePaymentStatus.Pending, invoice.OnlinePaymentStatus);
}
[Fact]
public async Task ProcessPayment_WhenSurchargeEnabled_PassesComputedSurcharge()
{
await using var context = CreateContext();
SeedCompany(context, connected: true, surchargeEnabled: true, surchargePercent: 3m);
SeedInvoice(context, total: 200m, amountPaid: 0m);
SeedReader(context);
await context.SaveChangesAsync();
var stripe = new Mock<IStripeConnectService>();
stripe.Setup(s => s.ProcessInvoicePaymentOnReaderAsync(
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<decimal>(), It.IsAny<decimal>(),
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<int>()))
.ReturnsAsync((true, "pi_456", (string?)null));
var controller = CreateController(context, stripe);
await controller.ProcessPayment(invoiceId: 1, readerId: 1, amount: 200m);
// 3% of 200 = 6.00
stripe.Verify(s => s.ProcessInvoicePaymentOnReaderAsync(
"acct_test", "tmr_test", 200m, 6m, "usd", "INV-1", 1), Times.Once);
}
// ── Helpers ───────────────────────────────────────────────────────────
private static TerminalController CreateController(ApplicationDbContext context, Mock<IStripeConnectService> stripe)
{
var uow = new UnitOfWork(context);
var tenant = new Mock<ITenantContext>();
tenant.Setup(t => t.GetCurrentCompanyId()).Returns(CompanyId);
var config = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?> { ["Stripe:Connect:SecretKey"] = "sk_test_abc" })
.Build();
var controller = new TerminalController(
uow, stripe.Object, tenant.Object, config, Mock.Of<ILogger<TerminalController>>())
{
ControllerContext = new() { HttpContext = new DefaultHttpContext() }
};
return controller;
}
private static void SeedCompany(ApplicationDbContext context, bool connected,
bool surchargeEnabled = false, decimal surchargePercent = 0m)
{
context.Companies.Add(new Company
{
Id = CompanyId,
CompanyName = "Test Shop",
StripeAccountId = connected ? "acct_test" : null,
StripeConnectStatus = connected ? StripeConnectStatus.Active : StripeConnectStatus.NotConnected,
StripeTerminalLocationId = "tml_test",
TerminalSurchargeEnabled = surchargeEnabled,
OnlinePaymentSurchargeType = surchargePercent > 0 ? OnlinePaymentSurchargeType.Percent : OnlinePaymentSurchargeType.None,
OnlinePaymentSurchargeValue = surchargePercent
});
}
private static void SeedInvoice(ApplicationDbContext context, decimal total, decimal amountPaid,
InvoiceStatus status = InvoiceStatus.Sent)
{
context.Invoices.Add(new Invoice
{
Id = 1,
CompanyId = CompanyId,
InvoiceNumber = "INV-1",
Total = total,
AmountPaid = amountPaid,
Status = status
});
}
private static void SeedReader(ApplicationDbContext context)
{
context.TerminalReaders.Add(new TerminalReader
{
Id = 1,
CompanyId = CompanyId,
StripeReaderId = "tmr_test",
StripeLocationId = "tml_test",
Label = "Front Counter",
DeviceType = "simulated_wisepos_e",
Status = TerminalReaderStatus.Active
});
}
private static JsonDocument ParseJson(Microsoft.AspNetCore.Mvc.IActionResult result)
{
var json = Assert.IsType<Microsoft.AspNetCore.Mvc.JsonResult>(result);
return JsonDocument.Parse(JsonSerializer.Serialize(json.Value));
}
private static ApplicationDbContext CreateContext()
{
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.Options;
// SuperAdmin principal so IsPlatformAdmin = true and the tenant query filter is bypassed in-test.
var identity = new ClaimsIdentity(new[] { new Claim(ClaimTypes.Role, "SuperAdmin") }, "Test");
var principal = new ClaimsPrincipal(identity);
byte[]? noBytes = null;
var sessionMock = new Mock<ISession>();
sessionMock.Setup(s => s.TryGetValue(It.IsAny<string>(), out noBytes)).Returns(false);
var httpContextMock = new Mock<HttpContext>();
httpContextMock.SetupGet(c => c.User).Returns(principal);
httpContextMock.SetupGet(c => c.Session).Returns(sessionMock.Object);
var accessor = new Mock<IHttpContextAccessor>();
accessor.SetupGet(a => a.HttpContext).Returns(httpContextMock.Object);
return new ApplicationDbContext(options, accessor.Object, null!);
}
}