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
24 changed files with 13281 additions and 8 deletions
@@ -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;
public interface IStripeConnectService
@@ -34,4 +36,75 @@ public interface IStripeConnectService
string currency,
string quoteNumber,
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 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>
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,
BankTransferACH = 3,
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
@@ -79,6 +79,7 @@ IRepository<ReworkRecord> ReworkRecords { get; }
IRepository<InvoiceItem> InvoiceItems { get; }
IRepository<Payment> Payments { get; }
IRepository<Deposit> Deposits { get; }
IRepository<TerminalReader> TerminalReaders { get; }
// Purchase Orders — typed repository for paged/filtered list and detail load
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).
/// </summary>
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
/// <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));
modelBuilder.Entity<Deposit>().HasQueryFilter(e =>
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
modelBuilder.Entity<TerminalReader>().HasQueryFilter(e =>
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
// Deposit → Invoice (nullable, no cascade)
modelBuilder.Entity<Deposit>()
@@ -1912,6 +1916,15 @@ modelBuilder.Entity<Job>()
.HasIndex(p => new { p.CompanyId, p.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>()
.HasOne<Company>()
.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")
.HasColumnType("nvarchar(max)");
b.Property<string>("StripeTerminalLocationId")
.HasColumnType("nvarchar(max)");
b.Property<DateTime?>("SubscriptionEndDate")
.HasColumnType("datetime2");
@@ -1923,6 +1926,9 @@ namespace PowderCoating.Infrastructure.Migrations
b.Property<int>("SubscriptionStatus")
.HasColumnType("int");
b.Property<bool>("TerminalSurchargeEnabled")
.HasColumnType("bit");
b.Property<string>("TimeZone")
.HasColumnType("nvarchar(max)");
@@ -7210,7 +7216,7 @@ namespace PowderCoating.Infrastructure.Migrations
{
Id = 1,
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",
DiscountPercent = 0m,
IsActive = true,
@@ -7221,7 +7227,7 @@ namespace PowderCoating.Infrastructure.Migrations
{
Id = 2,
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",
DiscountPercent = 5m,
IsActive = true,
@@ -7232,7 +7238,7 @@ namespace PowderCoating.Infrastructure.Migrations
{
Id = 3,
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",
DiscountPercent = 10m,
IsActive = true,
@@ -8738,6 +8744,78 @@ namespace PowderCoating.Infrastructure.Migrations
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 =>
{
b.Property<int>("Id")
@@ -150,6 +150,7 @@ public class UnitOfWork : IUnitOfWork
private IRepository<InvoiceItem>? _invoiceItems;
private IRepository<Payment>? _payments;
private IRepository<Deposit>? _deposits;
private IRepository<TerminalReader>? _terminalReaders;
// Expense Tracking / Accounts Payable
private IRepository<Account>? _accounts;
@@ -555,6 +556,10 @@ public class UnitOfWork : IUnitOfWork
public IRepository<Deposit> Deposits =>
_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
/// <summary>Repository for <see cref="Account"/> chart-of-accounts entries; supports self-referencing parent/child hierarchy.</summary>
public IRepository<Account> Accounts =>
@@ -1,5 +1,6 @@
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
using PowderCoating.Application.DTOs.Terminal;
using PowderCoating.Application.Interfaces;
using PowderCoating.Core.Enums;
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)
&& !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
var existing = await _unitOfWork.NotificationTemplates.FindAsync(t => t.CompanyId == companyId.Value);
var seeded = await EnsureNotificationTemplatesSeededAsync(companyId.Value, existing.ToList());
@@ -30,6 +30,7 @@ public class InvoicesController : Controller
private readonly INotificationService _notificationService;
private readonly IAccountBalanceService _accountBalanceService;
private readonly ICompanyLogoService _logoService;
private readonly IConfiguration _configuration;
public InvoicesController(
IUnitOfWork unitOfWork,
@@ -40,7 +41,8 @@ public class InvoicesController : Controller
ITenantContext tenantContext,
INotificationService notificationService,
IAccountBalanceService accountBalanceService,
ICompanyLogoService logoService)
ICompanyLogoService logoService,
IConfiguration configuration)
{
_unitOfWork = unitOfWork;
_mapper = mapper;
@@ -51,6 +53,7 @@ public class InvoicesController : Controller
_notificationService = notificationService;
_accountBalanceService = accountBalanceService;
_logoService = logoService;
_configuration = configuration;
}
private static readonly string[] StandardPaymentTerms =
@@ -278,6 +281,20 @@ public class InvoicesController : Controller
ViewBag.OnlinePaymentsEnabled = onlinePaymentsAllowed
&& 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
var expenseAccounts = await _unitOfWork.Accounts.FindAsync(
a => a.IsActive && a.AccountType == AccountType.Expense);
@@ -381,6 +381,11 @@ public class PaymentController : Controller
var dispute = stripeEvent.Data.Object as 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();
}
@@ -459,15 +464,24 @@ public class PaymentController : Controller
// Create a Payment record so the payment appears in AR and bank reports, and make the
// matching GL entries. Manual payments go through RecordPayment which does the same thing;
// this makes Stripe payments consistent with that path.
// 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 stripePayment = new Core.Entities.Payment
{
InvoiceId = invoice.Id,
Amount = netPayment,
PaymentDate = DateTime.UtcNow,
PaymentMethod = PowderCoating.Core.Enums.PaymentMethod.CreditDebitCard,
PaymentMethod = isTerminal
? PowderCoating.Core.Enums.PaymentMethod.CardReader
: PowderCoating.Core.Enums.PaymentMethod.CreditDebitCard,
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,
CompanyId = invoice.CompanyId,
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>
/// 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
@@ -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
- 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.
**Refunds:** Issue a refund from Invoice Details "Issue Refund."
@@ -41,6 +41,7 @@
@if (Model.AllowOnlinePayments)
{
<option value="online-payments">Online Payments</option>
<option value="card-readers">Card Readers</option>
}
<option value="kiosk">Kiosk</option>
<option value="timeclock">Timeclock</option>
@@ -113,6 +114,11 @@
<i class="bi bi-credit-card"></i> Online Payments
</button>
</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">
<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>
<!-- 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 -->
@@ -3843,6 +3945,10 @@
}
})();
</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>
// 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.
Voided invoices do not generate payment links.
</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 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="#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="#card-readers">In-Person Card Payments</a>
<a class="nav-link py-1 px-3 small text-body" href="#payment-reminders">Payment Reminders</a>
</nav>
</div>
@@ -644,6 +644,12 @@
<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
</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"
class="btn btn-outline-secondary" target="_blank" rel="noopener">
@@ -1042,6 +1048,67 @@
</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 -->
@if (!isVoided)
{
@@ -1531,6 +1598,18 @@
}
};
</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>
function submitSendInvoice(sendEmail, sendSms) {
document.getElementById('sendInvoiceSendEmail').value = sendEmail ? 'true' : 'false';
@@ -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; }
});
}
})();
@@ -514,7 +514,8 @@ public class PricingStageFlowTests
CreateTenantContext().Object,
Mock.Of<INotificationService>(),
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 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!);
}
}