Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f671f7e62e |
@@ -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; }
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
+11429
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 E reader.</p>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<p class="text-muted small">
|
||||
Take in-person card payments on a Stripe Terminal <strong>WisePOS 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 E)</h3>
|
||||
<p>
|
||||
Take a card payment in person against an invoice using a Stripe Terminal
|
||||
<strong>WisePOS E</strong> card reader. This is included with the same plan that allows
|
||||
online payments and runs on the same connected Stripe account — no separate merchant setup.
|
||||
</p>
|
||||
<ul class="mb-3">
|
||||
<li class="mb-2"><strong>One-time setup:</strong> go to <strong>Settings › Card Readers</strong>
|
||||
(the tab appears once Stripe is connected). On the reader, open
|
||||
<strong>Settings › Generate registration code</strong> to get a three-word code, enter it
|
||||
with a label such as “Front Counter,” 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 — 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 { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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">—</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 || '—') + '</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!);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user