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;
|
namespace PowderCoating.Application.Interfaces;
|
||||||
|
|
||||||
public interface IStripeConnectService
|
public interface IStripeConnectService
|
||||||
@@ -34,4 +36,75 @@ public interface IStripeConnectService
|
|||||||
string currency,
|
string currency,
|
||||||
string quoteNumber,
|
string quoteNumber,
|
||||||
int quoteId);
|
int quoteId);
|
||||||
|
|
||||||
|
// ----- Stripe Terminal (in-person card payments, WisePOS E) -----
|
||||||
|
// All methods route to the connected account via RequestOptions.StripeAccount, mirroring the
|
||||||
|
// online payment methods above. They return structured tuples instead of throwing.
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates the shop's single Stripe Terminal Location (one per company) from its address.
|
||||||
|
/// Readers must be attached to a Location. Returns the new Location id (tml_xxx).
|
||||||
|
/// </summary>
|
||||||
|
Task<(bool Success, string? LocationId, string? ErrorMessage)> CreateTerminalLocationAsync(
|
||||||
|
string connectedAccountId,
|
||||||
|
string displayName,
|
||||||
|
TerminalAddressDto address);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Registers a physical (or simulated) reader to the shop's Location using the registration
|
||||||
|
/// code shown on the device. Returns the reader id (tmr_xxx), device type and serial number.
|
||||||
|
/// </summary>
|
||||||
|
Task<(bool Success, string? ReaderId, string? DeviceType, string? SerialNumber, string? ErrorMessage)> RegisterReaderAsync(
|
||||||
|
string connectedAccountId,
|
||||||
|
string locationId,
|
||||||
|
string registrationCode,
|
||||||
|
string label);
|
||||||
|
|
||||||
|
/// <summary>Lists the readers attached to the shop's Location (for status refresh/reconciliation).</summary>
|
||||||
|
Task<(bool Success, IReadOnlyList<TerminalReaderDto> Readers, string? ErrorMessage)> ListReadersAsync(
|
||||||
|
string connectedAccountId,
|
||||||
|
string locationId);
|
||||||
|
|
||||||
|
/// <summary>Unregisters (deletes) a reader from Stripe.</summary>
|
||||||
|
Task<(bool Success, string? ErrorMessage)> DeleteReaderAsync(
|
||||||
|
string connectedAccountId,
|
||||||
|
string readerId);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates a card_present PaymentIntent for an invoice and pushes it to the physical reader,
|
||||||
|
/// which then prompts the customer to tap/insert/swipe. Metadata carries <c>source=terminal</c>
|
||||||
|
/// so the existing <c>payment_intent.succeeded</c> webhook records it as a card-reader payment.
|
||||||
|
/// Returns the PaymentIntent id so the caller can store it on the invoice for idempotency.
|
||||||
|
/// </summary>
|
||||||
|
Task<(bool Success, string? PaymentIntentId, string? ErrorMessage)> ProcessInvoicePaymentOnReaderAsync(
|
||||||
|
string connectedAccountId,
|
||||||
|
string readerId,
|
||||||
|
decimal amount,
|
||||||
|
decimal surchargeAmount,
|
||||||
|
string currency,
|
||||||
|
string invoiceNumber,
|
||||||
|
int invoiceId);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Reads the reader's current action status for live UI feedback. The authoritative payment
|
||||||
|
/// record is still created by the webhook — this is only for showing progress to the clerk.
|
||||||
|
/// </summary>
|
||||||
|
Task<(bool Success, string? ActionStatus, string? ActionType, string? PaymentIntentId,
|
||||||
|
string? FailureCode, string? FailureMessage, string? NetworkStatus, string? ErrorMessage)> GetReaderStatusAsync(
|
||||||
|
string connectedAccountId,
|
||||||
|
string readerId);
|
||||||
|
|
||||||
|
/// <summary>Cancels the reader's in-progress action (clerk cancelled or wants to retry).</summary>
|
||||||
|
Task<(bool Success, string? ErrorMessage)> CancelReaderActionAsync(
|
||||||
|
string connectedAccountId,
|
||||||
|
string readerId);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// TEST MODE ONLY: simulates a card tap on a simulated reader so the payment can complete
|
||||||
|
/// without physical hardware. Uses the Stripe TestHelpers Terminal API. Callers must guard
|
||||||
|
/// this to test mode; it is a no-op against real readers.
|
||||||
|
/// </summary>
|
||||||
|
Task<(bool Success, string? ErrorMessage)> SimulatePresentPaymentMethodAsync(
|
||||||
|
string connectedAccountId,
|
||||||
|
string readerId);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,6 +45,11 @@ public class Company : BaseEntity
|
|||||||
public decimal OnlinePaymentSurchargeValue { get; set; } = 0; // % or flat $ depending on type
|
public decimal OnlinePaymentSurchargeValue { get; set; } = 0; // % or flat $ depending on type
|
||||||
public bool OnlineSurchargeAcknowledged { get; set; } = false; // shop accepted compliance disclaimer
|
public bool OnlineSurchargeAcknowledged { get; set; } = false; // shop accepted compliance disclaimer
|
||||||
|
|
||||||
|
// Stripe Terminal — in-person card payments (WisePOS E). Runs on the same connected account
|
||||||
|
// as online payments; a single Terminal Location is created once per shop from its address.
|
||||||
|
public string? StripeTerminalLocationId { get; set; } // tml_xxx
|
||||||
|
public bool TerminalSurchargeEnabled { get; set; } = false; // default OFF — in-person surcharge rules vary by state
|
||||||
|
|
||||||
/// <summary>Internal notes about manual subscription changes (not shown to the company).</summary>
|
/// <summary>Internal notes about manual subscription changes (not shown to the company).</summary>
|
||||||
public string? SubscriptionNotes { get; set; }
|
public string? SubscriptionNotes { get; set; }
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
using PowderCoating.Core.Enums;
|
||||||
|
|
||||||
|
namespace PowderCoating.Core.Entities;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A Stripe Terminal card reader (e.g. a BBPOS WisePOS E) registered to a company for in-person
|
||||||
|
/// card payments. The reader lives on the company's Stripe Connect connected account and is attached
|
||||||
|
/// to the company's single Terminal Location (<see cref="Company.StripeTerminalLocationId"/>).
|
||||||
|
/// <para>
|
||||||
|
/// We mirror only the identifiers and a friendly label locally; Stripe remains the source of truth
|
||||||
|
/// for live network status. <see cref="Status"/> is a local lifecycle flag (Active/Deactivated),
|
||||||
|
/// separate from Stripe's transient online/offline network state captured in
|
||||||
|
/// <see cref="LastKnownNetworkStatus"/>.
|
||||||
|
/// </para>
|
||||||
|
/// </summary>
|
||||||
|
public class TerminalReader : BaseEntity
|
||||||
|
{
|
||||||
|
/// <summary>Stripe reader id (tmr_xxx) returned when the reader is registered.</summary>
|
||||||
|
public string StripeReaderId { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>Stripe Terminal Location id (tml_xxx) the reader is attached to — denormalized copy of <see cref="Company.StripeTerminalLocationId"/>.</summary>
|
||||||
|
public string StripeLocationId { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>Shop-friendly name, e.g. "Front Counter".</summary>
|
||||||
|
public string Label { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>Stripe device type, e.g. "bbpos_wisepos_e" or "simulated_wisepos_e" (test mode).</summary>
|
||||||
|
public string DeviceType { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>Hardware serial number reported by Stripe, when available.</summary>
|
||||||
|
public string? SerialNumber { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Local lifecycle state — whether the shop still uses this reader.</summary>
|
||||||
|
public TerminalReaderStatus Status { get; set; } = TerminalReaderStatus.Active;
|
||||||
|
|
||||||
|
/// <summary>Last network status snapshot from Stripe ("online"/"offline"), refreshed on poll. Advisory only.</summary>
|
||||||
|
public string? LastKnownNetworkStatus { get; set; }
|
||||||
|
|
||||||
|
/// <summary>When Stripe last saw the reader online, from the last status refresh.</summary>
|
||||||
|
public DateTime? LastSeenAt { get; set; }
|
||||||
|
}
|
||||||
@@ -33,7 +33,18 @@ public enum PaymentMethod
|
|||||||
CreditDebitCard = 2,
|
CreditDebitCard = 2,
|
||||||
BankTransferACH = 3,
|
BankTransferACH = 3,
|
||||||
DigitalPayment = 4,
|
DigitalPayment = 4,
|
||||||
StoreCredit = 5 // Refund issued as store credit (creates a CreditMemo)
|
StoreCredit = 5, // Refund issued as store credit (creates a CreditMemo)
|
||||||
|
CardReader = 6 // In-person card payment via a Stripe Terminal reader (WisePOS E)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Local lifecycle state for a registered Stripe Terminal card reader. Distinct from Stripe's
|
||||||
|
/// network status (online/offline) — this tracks whether the shop still uses the reader.
|
||||||
|
/// </summary>
|
||||||
|
public enum TerminalReaderStatus
|
||||||
|
{
|
||||||
|
Active = 0,
|
||||||
|
Deactivated = 1 // Unregistered from Stripe and hidden from the shop's reader list
|
||||||
}
|
}
|
||||||
|
|
||||||
public enum GiftCertificateStatus
|
public enum GiftCertificateStatus
|
||||||
|
|||||||
@@ -79,6 +79,7 @@ IRepository<ReworkRecord> ReworkRecords { get; }
|
|||||||
IRepository<InvoiceItem> InvoiceItems { get; }
|
IRepository<InvoiceItem> InvoiceItems { get; }
|
||||||
IRepository<Payment> Payments { get; }
|
IRepository<Payment> Payments { get; }
|
||||||
IRepository<Deposit> Deposits { get; }
|
IRepository<Deposit> Deposits { get; }
|
||||||
|
IRepository<TerminalReader> TerminalReaders { get; }
|
||||||
|
|
||||||
// Purchase Orders — typed repository for paged/filtered list and detail load
|
// Purchase Orders — typed repository for paged/filtered list and detail load
|
||||||
IPurchaseOrderRepository PurchaseOrders { get; }
|
IPurchaseOrderRepository PurchaseOrders { get; }
|
||||||
|
|||||||
@@ -321,6 +321,8 @@ public class ApplicationDbContext : IdentityDbContext<ApplicationUser>, IDataPro
|
|||||||
/// Auto-applied to the invoice when it is created (unapplied deposits are swept into Payment records).
|
/// Auto-applied to the invoice when it is created (unapplied deposits are swept into Payment records).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public DbSet<Deposit> Deposits { get; set; }
|
public DbSet<Deposit> Deposits { get; set; }
|
||||||
|
/// <summary>Registered Stripe Terminal card readers (WisePOS E) for in-person payments; tenant-filtered with soft delete.</summary>
|
||||||
|
public DbSet<TerminalReader> TerminalReaders { get; set; }
|
||||||
|
|
||||||
// Purchase Orders
|
// Purchase Orders
|
||||||
/// <summary>Purchase orders issued to vendors; tenant-filtered with soft delete.</summary>
|
/// <summary>Purchase orders issued to vendors; tenant-filtered with soft delete.</summary>
|
||||||
@@ -656,6 +658,8 @@ modelBuilder.Entity<ReworkRecord>().HasQueryFilter(e =>
|
|||||||
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
|
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
|
||||||
modelBuilder.Entity<Deposit>().HasQueryFilter(e =>
|
modelBuilder.Entity<Deposit>().HasQueryFilter(e =>
|
||||||
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
|
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
|
||||||
|
modelBuilder.Entity<TerminalReader>().HasQueryFilter(e =>
|
||||||
|
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
|
||||||
|
|
||||||
// Deposit → Invoice (nullable, no cascade)
|
// Deposit → Invoice (nullable, no cascade)
|
||||||
modelBuilder.Entity<Deposit>()
|
modelBuilder.Entity<Deposit>()
|
||||||
@@ -1912,6 +1916,15 @@ modelBuilder.Entity<Job>()
|
|||||||
.HasIndex(p => new { p.CompanyId, p.PaymentDate })
|
.HasIndex(p => new { p.CompanyId, p.PaymentDate })
|
||||||
.HasDatabaseName("IX_Payments_CompanyId_PaymentDate");
|
.HasDatabaseName("IX_Payments_CompanyId_PaymentDate");
|
||||||
|
|
||||||
|
// Terminal readers — looked up by Stripe id (webhook/registration) and by company (settings list)
|
||||||
|
modelBuilder.Entity<TerminalReader>()
|
||||||
|
.HasIndex(r => r.StripeReaderId)
|
||||||
|
.IsUnique()
|
||||||
|
.HasDatabaseName("IX_TerminalReaders_StripeReaderId");
|
||||||
|
modelBuilder.Entity<TerminalReader>()
|
||||||
|
.HasIndex(r => r.CompanyId)
|
||||||
|
.HasDatabaseName("IX_TerminalReaders_CompanyId");
|
||||||
|
|
||||||
modelBuilder.Entity<NotificationLog>()
|
modelBuilder.Entity<NotificationLog>()
|
||||||
.HasOne<Company>()
|
.HasOne<Company>()
|
||||||
.WithMany()
|
.WithMany()
|
||||||
|
|||||||
+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")
|
b.Property<string>("StripeSubscriptionId")
|
||||||
.HasColumnType("nvarchar(max)");
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<string>("StripeTerminalLocationId")
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
b.Property<DateTime?>("SubscriptionEndDate")
|
b.Property<DateTime?>("SubscriptionEndDate")
|
||||||
.HasColumnType("datetime2");
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
@@ -1923,6 +1926,9 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
b.Property<int>("SubscriptionStatus")
|
b.Property<int>("SubscriptionStatus")
|
||||||
.HasColumnType("int");
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<bool>("TerminalSurchargeEnabled")
|
||||||
|
.HasColumnType("bit");
|
||||||
|
|
||||||
b.Property<string>("TimeZone")
|
b.Property<string>("TimeZone")
|
||||||
.HasColumnType("nvarchar(max)");
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
@@ -7210,7 +7216,7 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
{
|
{
|
||||||
Id = 1,
|
Id = 1,
|
||||||
CompanyId = 0,
|
CompanyId = 0,
|
||||||
CreatedAt = new DateTime(2026, 6, 14, 1, 21, 46, 131, DateTimeKind.Utc).AddTicks(4191),
|
CreatedAt = new DateTime(2026, 6, 15, 22, 29, 10, 622, DateTimeKind.Utc).AddTicks(5996),
|
||||||
Description = "Standard pricing for regular customers",
|
Description = "Standard pricing for regular customers",
|
||||||
DiscountPercent = 0m,
|
DiscountPercent = 0m,
|
||||||
IsActive = true,
|
IsActive = true,
|
||||||
@@ -7221,7 +7227,7 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
{
|
{
|
||||||
Id = 2,
|
Id = 2,
|
||||||
CompanyId = 0,
|
CompanyId = 0,
|
||||||
CreatedAt = new DateTime(2026, 6, 14, 1, 21, 46, 131, DateTimeKind.Utc).AddTicks(4196),
|
CreatedAt = new DateTime(2026, 6, 15, 22, 29, 10, 622, DateTimeKind.Utc).AddTicks(6003),
|
||||||
Description = "5% discount for preferred customers",
|
Description = "5% discount for preferred customers",
|
||||||
DiscountPercent = 5m,
|
DiscountPercent = 5m,
|
||||||
IsActive = true,
|
IsActive = true,
|
||||||
@@ -7232,7 +7238,7 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
{
|
{
|
||||||
Id = 3,
|
Id = 3,
|
||||||
CompanyId = 0,
|
CompanyId = 0,
|
||||||
CreatedAt = new DateTime(2026, 6, 14, 1, 21, 46, 131, DateTimeKind.Utc).AddTicks(4197),
|
CreatedAt = new DateTime(2026, 6, 15, 22, 29, 10, 622, DateTimeKind.Utc).AddTicks(6004),
|
||||||
Description = "10% discount for premium customers",
|
Description = "10% discount for premium customers",
|
||||||
DiscountPercent = 10m,
|
DiscountPercent = 10m,
|
||||||
IsActive = true,
|
IsActive = true,
|
||||||
@@ -8738,6 +8744,78 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
b.ToTable("TaxRates");
|
b.ToTable("TaxRates");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("PowderCoating.Core.Entities.TerminalReader", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
|
||||||
|
|
||||||
|
b.Property<int>("CompanyId")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAt")
|
||||||
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
|
b.Property<string>("CreatedBy")
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("DeletedAt")
|
||||||
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
|
b.Property<string>("DeletedBy")
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<string>("DeviceType")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<bool>("IsDeleted")
|
||||||
|
.HasColumnType("bit");
|
||||||
|
|
||||||
|
b.Property<string>("Label")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<string>("LastKnownNetworkStatus")
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("LastSeenAt")
|
||||||
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
|
b.Property<string>("SerialNumber")
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<int>("Status")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<string>("StripeLocationId")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<string>("StripeReaderId")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("nvarchar(450)");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("UpdatedAt")
|
||||||
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
|
b.Property<string>("UpdatedBy")
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("CompanyId")
|
||||||
|
.HasDatabaseName("IX_TerminalReaders_CompanyId");
|
||||||
|
|
||||||
|
b.HasIndex("StripeReaderId")
|
||||||
|
.IsUnique()
|
||||||
|
.HasDatabaseName("IX_TerminalReaders_StripeReaderId");
|
||||||
|
|
||||||
|
b.ToTable("TerminalReaders");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("PowderCoating.Core.Entities.TermsAcceptance", b =>
|
modelBuilder.Entity("PowderCoating.Core.Entities.TermsAcceptance", b =>
|
||||||
{
|
{
|
||||||
b.Property<int>("Id")
|
b.Property<int>("Id")
|
||||||
|
|||||||
@@ -150,6 +150,7 @@ public class UnitOfWork : IUnitOfWork
|
|||||||
private IRepository<InvoiceItem>? _invoiceItems;
|
private IRepository<InvoiceItem>? _invoiceItems;
|
||||||
private IRepository<Payment>? _payments;
|
private IRepository<Payment>? _payments;
|
||||||
private IRepository<Deposit>? _deposits;
|
private IRepository<Deposit>? _deposits;
|
||||||
|
private IRepository<TerminalReader>? _terminalReaders;
|
||||||
|
|
||||||
// Expense Tracking / Accounts Payable
|
// Expense Tracking / Accounts Payable
|
||||||
private IRepository<Account>? _accounts;
|
private IRepository<Account>? _accounts;
|
||||||
@@ -555,6 +556,10 @@ public class UnitOfWork : IUnitOfWork
|
|||||||
public IRepository<Deposit> Deposits =>
|
public IRepository<Deposit> Deposits =>
|
||||||
_deposits ??= new Repository<Deposit>(_context);
|
_deposits ??= new Repository<Deposit>(_context);
|
||||||
|
|
||||||
|
/// <summary>Repository for <see cref="TerminalReader"/> registered Stripe Terminal card readers.</summary>
|
||||||
|
public IRepository<TerminalReader> TerminalReaders =>
|
||||||
|
_terminalReaders ??= new Repository<TerminalReader>(_context);
|
||||||
|
|
||||||
// Expense Tracking / Accounts Payable
|
// Expense Tracking / Accounts Payable
|
||||||
/// <summary>Repository for <see cref="Account"/> chart-of-accounts entries; supports self-referencing parent/child hierarchy.</summary>
|
/// <summary>Repository for <see cref="Account"/> chart-of-accounts entries; supports self-referencing parent/child hierarchy.</summary>
|
||||||
public IRepository<Account> Accounts =>
|
public IRepository<Account> Accounts =>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
using Microsoft.Extensions.Configuration;
|
using Microsoft.Extensions.Configuration;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
|
using PowderCoating.Application.DTOs.Terminal;
|
||||||
using PowderCoating.Application.Interfaces;
|
using PowderCoating.Application.Interfaces;
|
||||||
using PowderCoating.Core.Enums;
|
using PowderCoating.Core.Enums;
|
||||||
using PowderCoating.Core.Interfaces;
|
using PowderCoating.Core.Interfaces;
|
||||||
@@ -253,4 +254,258 @@ public class StripeConnectService : IStripeConnectService
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ===== Stripe Terminal (in-person card payments, WisePOS E) =====
|
||||||
|
|
||||||
|
/// <summary>True when the Connect secret key is a test-mode key (sk_test_…).</summary>
|
||||||
|
private bool IsTestMode => SecretKey.StartsWith("sk_test_", StringComparison.Ordinal);
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public async Task<(bool Success, string? LocationId, string? ErrorMessage)> CreateTerminalLocationAsync(
|
||||||
|
string connectedAccountId,
|
||||||
|
string displayName,
|
||||||
|
TerminalAddressDto address)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var options = new Stripe.Terminal.LocationCreateOptions
|
||||||
|
{
|
||||||
|
DisplayName = displayName,
|
||||||
|
Address = new AddressOptions
|
||||||
|
{
|
||||||
|
Line1 = address.Line1,
|
||||||
|
City = address.City,
|
||||||
|
State = address.State,
|
||||||
|
PostalCode = address.PostalCode,
|
||||||
|
Country = address.Country
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
var client = new StripeClient(SecretKey);
|
||||||
|
var requestOptions = new RequestOptions { StripeAccount = connectedAccountId };
|
||||||
|
var service = new Stripe.Terminal.LocationService(client);
|
||||||
|
var location = await service.CreateAsync(options, requestOptions);
|
||||||
|
|
||||||
|
return (true, location.Id, null);
|
||||||
|
}
|
||||||
|
catch (StripeException ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Failed to create Terminal Location for account {AccountId}", connectedAccountId);
|
||||||
|
return (false, null, ex.StripeError?.Message ?? ex.Message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public async Task<(bool Success, string? ReaderId, string? DeviceType, string? SerialNumber, string? ErrorMessage)> RegisterReaderAsync(
|
||||||
|
string connectedAccountId,
|
||||||
|
string locationId,
|
||||||
|
string registrationCode,
|
||||||
|
string label)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var options = new Stripe.Terminal.ReaderCreateOptions
|
||||||
|
{
|
||||||
|
RegistrationCode = registrationCode,
|
||||||
|
Label = label,
|
||||||
|
Location = locationId
|
||||||
|
};
|
||||||
|
|
||||||
|
var client = new StripeClient(SecretKey);
|
||||||
|
var requestOptions = new RequestOptions { StripeAccount = connectedAccountId };
|
||||||
|
var service = new Stripe.Terminal.ReaderService(client);
|
||||||
|
var reader = await service.CreateAsync(options, requestOptions);
|
||||||
|
|
||||||
|
return (true, reader.Id, reader.DeviceType, reader.SerialNumber, null);
|
||||||
|
}
|
||||||
|
catch (StripeException ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Failed to register Terminal reader for account {AccountId}", connectedAccountId);
|
||||||
|
return (false, null, null, null, ex.StripeError?.Message ?? ex.Message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public async Task<(bool Success, IReadOnlyList<TerminalReaderDto> Readers, string? ErrorMessage)> ListReadersAsync(
|
||||||
|
string connectedAccountId,
|
||||||
|
string locationId)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var options = new Stripe.Terminal.ReaderListOptions { Location = locationId };
|
||||||
|
var client = new StripeClient(SecretKey);
|
||||||
|
var requestOptions = new RequestOptions { StripeAccount = connectedAccountId };
|
||||||
|
var service = new Stripe.Terminal.ReaderService(client);
|
||||||
|
var readers = await service.ListAsync(options, requestOptions);
|
||||||
|
|
||||||
|
var dtos = readers.Data.Select(r => new TerminalReaderDto
|
||||||
|
{
|
||||||
|
StripeReaderId = r.Id,
|
||||||
|
Label = r.Label,
|
||||||
|
DeviceType = r.DeviceType,
|
||||||
|
SerialNumber = r.SerialNumber,
|
||||||
|
NetworkStatus = r.Status
|
||||||
|
}).ToList();
|
||||||
|
|
||||||
|
return (true, dtos, null);
|
||||||
|
}
|
||||||
|
catch (StripeException ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Failed to list Terminal readers for account {AccountId}", connectedAccountId);
|
||||||
|
return (false, Array.Empty<TerminalReaderDto>(), ex.StripeError?.Message ?? ex.Message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public async Task<(bool Success, string? ErrorMessage)> DeleteReaderAsync(
|
||||||
|
string connectedAccountId,
|
||||||
|
string readerId)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var client = new StripeClient(SecretKey);
|
||||||
|
var requestOptions = new RequestOptions { StripeAccount = connectedAccountId };
|
||||||
|
var service = new Stripe.Terminal.ReaderService(client);
|
||||||
|
await service.DeleteAsync(readerId, null, requestOptions);
|
||||||
|
return (true, null);
|
||||||
|
}
|
||||||
|
catch (StripeException ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Failed to delete Terminal reader {ReaderId}", readerId);
|
||||||
|
return (false, ex.StripeError?.Message ?? ex.Message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public async Task<(bool Success, string? PaymentIntentId, string? ErrorMessage)> ProcessInvoicePaymentOnReaderAsync(
|
||||||
|
string connectedAccountId,
|
||||||
|
string readerId,
|
||||||
|
decimal amount,
|
||||||
|
decimal surchargeAmount,
|
||||||
|
string currency,
|
||||||
|
string invoiceNumber,
|
||||||
|
int invoiceId)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var totalWithSurcharge = amount + surchargeAmount;
|
||||||
|
var amountInCents = (long)Math.Round(totalWithSurcharge * 100, MidpointRounding.AwayFromZero);
|
||||||
|
|
||||||
|
var client = new StripeClient(SecretKey);
|
||||||
|
var requestOptions = new RequestOptions { StripeAccount = connectedAccountId };
|
||||||
|
|
||||||
|
// 1) Create a card_present PaymentIntent. Note: do NOT set AutomaticPaymentMethods here —
|
||||||
|
// it is incompatible with an explicit card_present payment method type.
|
||||||
|
var piOptions = new PaymentIntentCreateOptions
|
||||||
|
{
|
||||||
|
Amount = amountInCents,
|
||||||
|
Currency = currency.ToLower(),
|
||||||
|
Description = $"Invoice {invoiceNumber}",
|
||||||
|
PaymentMethodTypes = new List<string> { "card_present" },
|
||||||
|
CaptureMethod = "automatic",
|
||||||
|
Metadata = new Dictionary<string, string>
|
||||||
|
{
|
||||||
|
{ "invoice_id", invoiceId.ToString() },
|
||||||
|
{ "invoice_number", invoiceNumber },
|
||||||
|
{ "surcharge_amount", surchargeAmount.ToString("F2") },
|
||||||
|
{ "source", "terminal" }
|
||||||
|
}
|
||||||
|
};
|
||||||
|
var piService = new PaymentIntentService(client);
|
||||||
|
var intent = await piService.CreateAsync(piOptions, requestOptions);
|
||||||
|
|
||||||
|
// 2) Push the PaymentIntent to the physical reader; it prompts the customer to present a card.
|
||||||
|
var processOptions = new Stripe.Terminal.ReaderProcessPaymentIntentOptions
|
||||||
|
{
|
||||||
|
PaymentIntent = intent.Id,
|
||||||
|
ProcessConfig = new Stripe.Terminal.ReaderProcessConfigOptions
|
||||||
|
{
|
||||||
|
EnableCustomerCancellation = true,
|
||||||
|
SkipTipping = true
|
||||||
|
}
|
||||||
|
};
|
||||||
|
var readerService = new Stripe.Terminal.ReaderService(client);
|
||||||
|
await readerService.ProcessPaymentIntentAsync(readerId, processOptions, requestOptions);
|
||||||
|
|
||||||
|
return (true, intent.Id, null);
|
||||||
|
}
|
||||||
|
catch (StripeException ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Failed to process Terminal payment for invoice {InvoiceId} on reader {ReaderId}", invoiceId, readerId);
|
||||||
|
return (false, null, ex.StripeError?.Message ?? ex.Message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public async Task<(bool Success, string? ActionStatus, string? ActionType, string? PaymentIntentId,
|
||||||
|
string? FailureCode, string? FailureMessage, string? NetworkStatus, string? ErrorMessage)> GetReaderStatusAsync(
|
||||||
|
string connectedAccountId,
|
||||||
|
string readerId)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var client = new StripeClient(SecretKey);
|
||||||
|
var requestOptions = new RequestOptions { StripeAccount = connectedAccountId };
|
||||||
|
var service = new Stripe.Terminal.ReaderService(client);
|
||||||
|
var reader = await service.GetAsync(readerId, null, requestOptions);
|
||||||
|
|
||||||
|
var action = reader.Action;
|
||||||
|
return (
|
||||||
|
true,
|
||||||
|
action?.Status,
|
||||||
|
action?.Type,
|
||||||
|
action?.ProcessPaymentIntent?.PaymentIntentId,
|
||||||
|
action?.FailureCode,
|
||||||
|
action?.FailureMessage,
|
||||||
|
reader.Status,
|
||||||
|
null);
|
||||||
|
}
|
||||||
|
catch (StripeException ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Failed to read Terminal reader status {ReaderId}", readerId);
|
||||||
|
return (false, null, null, null, null, null, null, ex.StripeError?.Message ?? ex.Message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public async Task<(bool Success, string? ErrorMessage)> CancelReaderActionAsync(
|
||||||
|
string connectedAccountId,
|
||||||
|
string readerId)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var client = new StripeClient(SecretKey);
|
||||||
|
var requestOptions = new RequestOptions { StripeAccount = connectedAccountId };
|
||||||
|
var service = new Stripe.Terminal.ReaderService(client);
|
||||||
|
await service.CancelActionAsync(readerId, null, requestOptions);
|
||||||
|
return (true, null);
|
||||||
|
}
|
||||||
|
catch (StripeException ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Failed to cancel Terminal reader action {ReaderId}", readerId);
|
||||||
|
return (false, ex.StripeError?.Message ?? ex.Message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public async Task<(bool Success, string? ErrorMessage)> SimulatePresentPaymentMethodAsync(
|
||||||
|
string connectedAccountId,
|
||||||
|
string readerId)
|
||||||
|
{
|
||||||
|
if (!IsTestMode)
|
||||||
|
return (false, "Simulated card taps are only available in Stripe test mode.");
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var client = new StripeClient(SecretKey);
|
||||||
|
var requestOptions = new RequestOptions { StripeAccount = connectedAccountId };
|
||||||
|
var service = new Stripe.TestHelpers.Terminal.ReaderService(client);
|
||||||
|
await service.PresentPaymentMethodAsync(readerId, new Stripe.TestHelpers.Terminal.ReaderPresentPaymentMethodOptions(), requestOptions);
|
||||||
|
return (true, null);
|
||||||
|
}
|
||||||
|
catch (StripeException ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Failed to simulate card tap on reader {ReaderId}", readerId);
|
||||||
|
return (false, ex.StripeError?.Message ?? ex.Message);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -158,6 +158,11 @@ public class CompanySettingsController : Controller
|
|||||||
ViewBag.StripeConnectConfigured = !string.IsNullOrWhiteSpace(connectClientId)
|
ViewBag.StripeConnectConfigured = !string.IsNullOrWhiteSpace(connectClientId)
|
||||||
&& !connectClientId.Contains("your_connect_client_id_here", StringComparison.OrdinalIgnoreCase);
|
&& !connectClientId.Contains("your_connect_client_id_here", StringComparison.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
// Stripe Terminal (Card Readers tab) — current in-person surcharge toggle + test-mode flag
|
||||||
|
ViewBag.TerminalSurchargeEnabled = company.TerminalSurchargeEnabled;
|
||||||
|
ViewBag.TerminalTestMode =
|
||||||
|
(_configuration["Stripe:Connect:SecretKey"] ?? string.Empty).StartsWith("sk_test_", StringComparison.Ordinal);
|
||||||
|
|
||||||
// Load notification templates for inline tab
|
// Load notification templates for inline tab
|
||||||
var existing = await _unitOfWork.NotificationTemplates.FindAsync(t => t.CompanyId == companyId.Value);
|
var existing = await _unitOfWork.NotificationTemplates.FindAsync(t => t.CompanyId == companyId.Value);
|
||||||
var seeded = await EnsureNotificationTemplatesSeededAsync(companyId.Value, existing.ToList());
|
var seeded = await EnsureNotificationTemplatesSeededAsync(companyId.Value, existing.ToList());
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ public class InvoicesController : Controller
|
|||||||
private readonly INotificationService _notificationService;
|
private readonly INotificationService _notificationService;
|
||||||
private readonly IAccountBalanceService _accountBalanceService;
|
private readonly IAccountBalanceService _accountBalanceService;
|
||||||
private readonly ICompanyLogoService _logoService;
|
private readonly ICompanyLogoService _logoService;
|
||||||
|
private readonly IConfiguration _configuration;
|
||||||
|
|
||||||
public InvoicesController(
|
public InvoicesController(
|
||||||
IUnitOfWork unitOfWork,
|
IUnitOfWork unitOfWork,
|
||||||
@@ -40,7 +41,8 @@ public class InvoicesController : Controller
|
|||||||
ITenantContext tenantContext,
|
ITenantContext tenantContext,
|
||||||
INotificationService notificationService,
|
INotificationService notificationService,
|
||||||
IAccountBalanceService accountBalanceService,
|
IAccountBalanceService accountBalanceService,
|
||||||
ICompanyLogoService logoService)
|
ICompanyLogoService logoService,
|
||||||
|
IConfiguration configuration)
|
||||||
{
|
{
|
||||||
_unitOfWork = unitOfWork;
|
_unitOfWork = unitOfWork;
|
||||||
_mapper = mapper;
|
_mapper = mapper;
|
||||||
@@ -51,6 +53,7 @@ public class InvoicesController : Controller
|
|||||||
_notificationService = notificationService;
|
_notificationService = notificationService;
|
||||||
_accountBalanceService = accountBalanceService;
|
_accountBalanceService = accountBalanceService;
|
||||||
_logoService = logoService;
|
_logoService = logoService;
|
||||||
|
_configuration = configuration;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static readonly string[] StandardPaymentTerms =
|
private static readonly string[] StandardPaymentTerms =
|
||||||
@@ -278,6 +281,20 @@ public class InvoicesController : Controller
|
|||||||
ViewBag.OnlinePaymentsEnabled = onlinePaymentsAllowed
|
ViewBag.OnlinePaymentsEnabled = onlinePaymentsAllowed
|
||||||
&& company?.StripeConnectStatus == StripeConnectStatus.Active;
|
&& company?.StripeConnectStatus == StripeConnectStatus.Active;
|
||||||
|
|
||||||
|
// In-person card reader (Stripe Terminal) — bundled with the online-payments entitlement.
|
||||||
|
// Surface the active readers + a "Take Card Payment" button only when at least one exists.
|
||||||
|
var terminalReaders = (await _unitOfWork.TerminalReaders.FindAsync(
|
||||||
|
r => r.Status == Core.Enums.TerminalReaderStatus.Active))
|
||||||
|
.OrderBy(r => r.Label)
|
||||||
|
.Select(r => new SelectListItem(r.Label, r.Id.ToString()))
|
||||||
|
.ToList();
|
||||||
|
ViewBag.TerminalReaders = terminalReaders;
|
||||||
|
ViewBag.TerminalPaymentsEnabled = onlinePaymentsAllowed
|
||||||
|
&& company?.StripeConnectStatus == StripeConnectStatus.Active
|
||||||
|
&& terminalReaders.Count > 0;
|
||||||
|
ViewBag.TerminalTestMode =
|
||||||
|
(_configuration["Stripe:Connect:SecretKey"] ?? string.Empty).StartsWith("sk_test_", StringComparison.Ordinal);
|
||||||
|
|
||||||
// Expense accounts for the write-off bad-debt modal
|
// Expense accounts for the write-off bad-debt modal
|
||||||
var expenseAccounts = await _unitOfWork.Accounts.FindAsync(
|
var expenseAccounts = await _unitOfWork.Accounts.FindAsync(
|
||||||
a => a.IsActive && a.AccountType == AccountType.Expense);
|
a => a.IsActive && a.AccountType == AccountType.Expense);
|
||||||
|
|||||||
@@ -381,6 +381,11 @@ public class PaymentController : Controller
|
|||||||
var dispute = stripeEvent.Data.Object as Dispute;
|
var dispute = stripeEvent.Data.Object as Dispute;
|
||||||
if (dispute != null) await HandleDisputeClosedAsync(dispute);
|
if (dispute != null) await HandleDisputeClosedAsync(dispute);
|
||||||
}
|
}
|
||||||
|
else if (stripeEvent.Type == "terminal.reader.action_failed")
|
||||||
|
{
|
||||||
|
var reader = stripeEvent.Data.Object as Stripe.Terminal.Reader;
|
||||||
|
if (reader != null) await HandleReaderActionFailedAsync(reader);
|
||||||
|
}
|
||||||
|
|
||||||
return Ok();
|
return Ok();
|
||||||
}
|
}
|
||||||
@@ -459,15 +464,24 @@ public class PaymentController : Controller
|
|||||||
// Create a Payment record so the payment appears in AR and bank reports, and make the
|
// Create a Payment record so the payment appears in AR and bank reports, and make the
|
||||||
// matching GL entries. Manual payments go through RecordPayment which does the same thing;
|
// matching GL entries. Manual payments go through RecordPayment which does the same thing;
|
||||||
// this makes Stripe payments consistent with that path.
|
// this makes Stripe payments consistent with that path.
|
||||||
|
// In-person Terminal payments carry source=terminal so we can record them as a card-reader
|
||||||
|
// payment (vs an online card-not-present payment) for clearer reporting. Everything else —
|
||||||
|
// GL posting, status machine, notifications — is identical.
|
||||||
|
var isTerminal = intent.Metadata.GetValueOrDefault("source") == "terminal";
|
||||||
|
|
||||||
var (arAcctId, checkingAcctId) = await GetGlAccountIdsAsync(invoice.CompanyId);
|
var (arAcctId, checkingAcctId) = await GetGlAccountIdsAsync(invoice.CompanyId);
|
||||||
var stripePayment = new Core.Entities.Payment
|
var stripePayment = new Core.Entities.Payment
|
||||||
{
|
{
|
||||||
InvoiceId = invoice.Id,
|
InvoiceId = invoice.Id,
|
||||||
Amount = netPayment,
|
Amount = netPayment,
|
||||||
PaymentDate = DateTime.UtcNow,
|
PaymentDate = DateTime.UtcNow,
|
||||||
PaymentMethod = PowderCoating.Core.Enums.PaymentMethod.CreditDebitCard,
|
PaymentMethod = isTerminal
|
||||||
|
? PowderCoating.Core.Enums.PaymentMethod.CardReader
|
||||||
|
: PowderCoating.Core.Enums.PaymentMethod.CreditDebitCard,
|
||||||
Reference = intent.Id,
|
Reference = intent.Id,
|
||||||
Notes = $"Online payment via Stripe. Surcharge: {surcharge:C}",
|
Notes = isTerminal
|
||||||
|
? $"In-person card payment via Stripe Terminal. Surcharge: {surcharge:C}"
|
||||||
|
: $"Online payment via Stripe. Surcharge: {surcharge:C}",
|
||||||
DepositAccountId = checkingAcctId,
|
DepositAccountId = checkingAcctId,
|
||||||
CompanyId = invoice.CompanyId,
|
CompanyId = invoice.CompanyId,
|
||||||
CreatedAt = DateTime.UtcNow
|
CreatedAt = DateTime.UtcNow
|
||||||
@@ -502,6 +516,45 @@ public class PaymentController : Controller
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Handles a <c>terminal.reader.action_failed</c> event (declined card, customer cancellation,
|
||||||
|
/// reader timeout). This is observability only — no payment occurred, so nothing is written to the
|
||||||
|
/// ledger. The clerk's live status poll is the primary feedback channel; this fires an in-app
|
||||||
|
/// notification as a backstop in case they navigated away. Resolves the company via the invoice the
|
||||||
|
/// failed PaymentIntent was created for. Uses <c>IgnoreQueryFilters</c> (no tenant context here).
|
||||||
|
/// </summary>
|
||||||
|
private async Task HandleReaderActionFailedAsync(Stripe.Terminal.Reader reader)
|
||||||
|
{
|
||||||
|
var failureMessage = reader.Action?.FailureMessage ?? "The card reader payment did not complete.";
|
||||||
|
var paymentIntentId = reader.Action?.ProcessPaymentIntent?.PaymentIntentId;
|
||||||
|
|
||||||
|
_logger.LogWarning("Terminal reader {ReaderId} action failed: {Code} {Message} (PI={PI})",
|
||||||
|
reader.Id, reader.Action?.FailureCode, failureMessage, paymentIntentId);
|
||||||
|
|
||||||
|
if (string.IsNullOrEmpty(paymentIntentId)) return;
|
||||||
|
|
||||||
|
var invoice = await _context.Invoices
|
||||||
|
.IgnoreQueryFilters()
|
||||||
|
.FirstOrDefaultAsync(i => i.StripePaymentIntentId == paymentIntentId && !i.IsDeleted);
|
||||||
|
if (invoice == null) return;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await _inApp.CreateAsync(
|
||||||
|
companyId: invoice.CompanyId,
|
||||||
|
title: "Card Reader Payment Failed",
|
||||||
|
message: $"The card reader payment for invoice {invoice.InvoiceNumber} failed: {failureMessage}",
|
||||||
|
notificationType: "PaymentFailed",
|
||||||
|
link: $"/Invoices/Details/{invoice.Id}",
|
||||||
|
invoiceId: invoice.Id,
|
||||||
|
customerId: invoice.CustomerId);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex, "In-app notification failed for terminal failure on invoice {InvoiceId}", invoice.Id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Processes a successful <c>payment_intent.succeeded</c> event for a quote deposit. Creates a
|
/// Processes a successful <c>payment_intent.succeeded</c> event for a quote deposit. Creates a
|
||||||
/// <c>Deposit</c> ledger record so the deposit appears in the customer's deposit history and can
|
/// <c>Deposit</c> ledger record so the deposit appears in the customer's deposit history and can
|
||||||
|
|||||||
@@ -0,0 +1,342 @@
|
|||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using PowderCoating.Application.DTOs.Terminal;
|
||||||
|
using PowderCoating.Application.Interfaces;
|
||||||
|
using PowderCoating.Core.Entities;
|
||||||
|
using PowderCoating.Core.Enums;
|
||||||
|
using PowderCoating.Core.Interfaces;
|
||||||
|
using PowderCoating.Shared.Constants;
|
||||||
|
|
||||||
|
namespace PowderCoating.Web.Controllers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Authenticated, tenant-scoped controller for Stripe Terminal in-person card payments (WisePOS E).
|
||||||
|
/// Handles reader registration/management (admin) and pushing an invoice payment to a physical reader
|
||||||
|
/// plus live status polling (anyone who can manage invoices).
|
||||||
|
/// <para>
|
||||||
|
/// The authoritative payment record is always created by the existing <c>payment_intent.succeeded</c>
|
||||||
|
/// webhook in <see cref="PaymentController"/> — this controller only kicks off the charge on the reader
|
||||||
|
/// and reports progress. See <c>docs</c>/the plan for the full flow.
|
||||||
|
/// </para>
|
||||||
|
/// </summary>
|
||||||
|
[Authorize(Policy = AppConstants.Policies.CanManageInvoices)]
|
||||||
|
public class TerminalController : Controller
|
||||||
|
{
|
||||||
|
private readonly IUnitOfWork _unitOfWork;
|
||||||
|
private readonly IStripeConnectService _stripeConnect;
|
||||||
|
private readonly ITenantContext _tenantContext;
|
||||||
|
private readonly IConfiguration _configuration;
|
||||||
|
private readonly ILogger<TerminalController> _logger;
|
||||||
|
|
||||||
|
public TerminalController(
|
||||||
|
IUnitOfWork unitOfWork,
|
||||||
|
IStripeConnectService stripeConnect,
|
||||||
|
ITenantContext tenantContext,
|
||||||
|
IConfiguration configuration,
|
||||||
|
ILogger<TerminalController> logger)
|
||||||
|
{
|
||||||
|
_unitOfWork = unitOfWork;
|
||||||
|
_stripeConnect = stripeConnect;
|
||||||
|
_tenantContext = tenantContext;
|
||||||
|
_configuration = configuration;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Current tenant's company id. The CanManageInvoices policy guarantees a company-scoped user;
|
||||||
|
/// a 0 fallback fails safe (matches no company) for the theoretical claim-less case.</summary>
|
||||||
|
private int CompanyId => _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||||
|
|
||||||
|
/// <summary>True when the Connect secret key is a test-mode key — gates the simulated-tap endpoint.</summary>
|
||||||
|
private bool IsTestMode =>
|
||||||
|
(_configuration["Stripe:Connect:SecretKey"] ?? string.Empty).StartsWith("sk_test_", StringComparison.Ordinal);
|
||||||
|
|
||||||
|
// ===== Reader management (admin) =====
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Registers a Stripe Terminal reader to the company using the registration code shown on the
|
||||||
|
/// device. Lazily creates the company's single Terminal Location from its address on first use.
|
||||||
|
/// Requires company-admin rights in addition to the controller's invoice policy.
|
||||||
|
/// </summary>
|
||||||
|
[HttpPost, ValidateAntiForgeryToken]
|
||||||
|
[Authorize(Policy = AppConstants.Policies.CompanyAdminOnly)]
|
||||||
|
public async Task<IActionResult> RegisterReader(string registrationCode, string label)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(registrationCode) || string.IsNullOrWhiteSpace(label))
|
||||||
|
return Json(new { success = false, error = "A registration code and a label are both required." });
|
||||||
|
|
||||||
|
var companyId = CompanyId;
|
||||||
|
var company = await _unitOfWork.Companies.GetByIdAsync(companyId);
|
||||||
|
if (company == null)
|
||||||
|
return Json(new { success = false, error = "Company not found." });
|
||||||
|
if (company.StripeConnectStatus != StripeConnectStatus.Active || string.IsNullOrEmpty(company.StripeAccountId))
|
||||||
|
return Json(new { success = false, error = "Connect your Stripe account before registering a reader." });
|
||||||
|
|
||||||
|
// Ensure the shop's Terminal Location exists (one per company).
|
||||||
|
var (locOk, locationId, locError) = await EnsureLocationAsync(company);
|
||||||
|
if (!locOk)
|
||||||
|
return Json(new { success = false, error = locError });
|
||||||
|
|
||||||
|
var (ok, readerId, deviceType, serial, error) = await _stripeConnect.RegisterReaderAsync(
|
||||||
|
company.StripeAccountId!, locationId!, registrationCode.Trim(), label.Trim());
|
||||||
|
if (!ok)
|
||||||
|
return Json(new { success = false, error });
|
||||||
|
|
||||||
|
var reader = new TerminalReader
|
||||||
|
{
|
||||||
|
CompanyId = companyId,
|
||||||
|
StripeReaderId = readerId!,
|
||||||
|
StripeLocationId = locationId!,
|
||||||
|
Label = label.Trim(),
|
||||||
|
DeviceType = deviceType ?? string.Empty,
|
||||||
|
SerialNumber = serial,
|
||||||
|
Status = TerminalReaderStatus.Active
|
||||||
|
};
|
||||||
|
await _unitOfWork.TerminalReaders.AddAsync(reader);
|
||||||
|
await _unitOfWork.CompleteAsync();
|
||||||
|
|
||||||
|
return Json(new { success = true, reader = ToJson(reader) });
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Saves the in-person (Terminal) surcharge toggle. Defaults off; enabling it applies the same
|
||||||
|
/// percent/flat fee configured for online payments to card-reader charges. Admin only.
|
||||||
|
/// </summary>
|
||||||
|
[HttpPost, ValidateAntiForgeryToken]
|
||||||
|
[Authorize(Policy = AppConstants.Policies.CompanyAdminOnly)]
|
||||||
|
public async Task<IActionResult> UpdateTerminalSettings(bool surchargeEnabled)
|
||||||
|
{
|
||||||
|
var companyId = CompanyId;
|
||||||
|
var company = await _unitOfWork.Companies.GetByIdAsync(companyId);
|
||||||
|
if (company == null)
|
||||||
|
return Json(new { success = false, error = "Company not found." });
|
||||||
|
|
||||||
|
company.TerminalSurchargeEnabled = surchargeEnabled;
|
||||||
|
company.UpdatedAt = DateTime.UtcNow;
|
||||||
|
await _unitOfWork.Companies.UpdateAsync(company);
|
||||||
|
await _unitOfWork.CompleteAsync();
|
||||||
|
|
||||||
|
return Json(new { success = true });
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Returns the company's active registered readers (JSON) for the settings tab.</summary>
|
||||||
|
[HttpGet]
|
||||||
|
public async Task<IActionResult> ListReaders()
|
||||||
|
{
|
||||||
|
var companyId = CompanyId;
|
||||||
|
var readers = await _unitOfWork.TerminalReaders.FindAsync(
|
||||||
|
r => r.CompanyId == companyId && r.Status == TerminalReaderStatus.Active);
|
||||||
|
return Json(new { success = true, readers = readers.OrderBy(r => r.Label).Select(ToJson) });
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Unregisters a reader from Stripe and soft-deletes the local record.</summary>
|
||||||
|
[HttpPost, ValidateAntiForgeryToken]
|
||||||
|
[Authorize(Policy = AppConstants.Policies.CompanyAdminOnly)]
|
||||||
|
public async Task<IActionResult> DeactivateReader(int id)
|
||||||
|
{
|
||||||
|
var companyId = CompanyId;
|
||||||
|
var reader = await _unitOfWork.TerminalReaders.GetByIdAsync(id);
|
||||||
|
if (reader == null || reader.CompanyId != companyId)
|
||||||
|
return Json(new { success = false, error = "Reader not found." });
|
||||||
|
|
||||||
|
var company = await _unitOfWork.Companies.GetByIdAsync(companyId);
|
||||||
|
if (company?.StripeAccountId != null)
|
||||||
|
{
|
||||||
|
// Best-effort delete on Stripe; proceed with local cleanup even if it has already been removed there.
|
||||||
|
var (ok, error) = await _stripeConnect.DeleteReaderAsync(company.StripeAccountId, reader.StripeReaderId);
|
||||||
|
if (!ok)
|
||||||
|
_logger.LogWarning("Stripe reader delete failed for {ReaderId}: {Error}", reader.StripeReaderId, error);
|
||||||
|
}
|
||||||
|
|
||||||
|
reader.Status = TerminalReaderStatus.Deactivated;
|
||||||
|
await _unitOfWork.TerminalReaders.SoftDeleteAsync(reader);
|
||||||
|
await _unitOfWork.CompleteAsync();
|
||||||
|
|
||||||
|
return Json(new { success = true });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== Taking a payment =====
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates a card_present PaymentIntent for the invoice and pushes it to the selected reader.
|
||||||
|
/// Stores the returned PaymentIntent id on the invoice so the webhook's idempotency guard works.
|
||||||
|
/// Does NOT record the payment — the <c>payment_intent.succeeded</c> webhook is authoritative.
|
||||||
|
/// </summary>
|
||||||
|
[HttpPost, ValidateAntiForgeryToken]
|
||||||
|
public async Task<IActionResult> ProcessPayment(int invoiceId, int readerId, decimal amount)
|
||||||
|
{
|
||||||
|
var companyId = CompanyId;
|
||||||
|
|
||||||
|
var company = await _unitOfWork.Companies.GetByIdAsync(companyId);
|
||||||
|
if (company == null || company.StripeConnectStatus != StripeConnectStatus.Active || string.IsNullOrEmpty(company.StripeAccountId))
|
||||||
|
return Json(new { success = false, error = "Stripe is not connected for this company." });
|
||||||
|
|
||||||
|
var invoice = await _unitOfWork.Invoices.GetByIdAsync(invoiceId);
|
||||||
|
if (invoice == null || invoice.CompanyId != companyId)
|
||||||
|
return Json(new { success = false, error = "Invoice not found." });
|
||||||
|
if (invoice.Status == InvoiceStatus.Voided)
|
||||||
|
return Json(new { success = false, error = "This invoice has been voided." });
|
||||||
|
if (invoice.BalanceDue <= 0)
|
||||||
|
return Json(new { success = false, error = "This invoice is already paid in full." });
|
||||||
|
if (amount <= 0 || amount > invoice.BalanceDue)
|
||||||
|
return Json(new { success = false, error = "Enter an amount between $0 and the balance due." });
|
||||||
|
|
||||||
|
var reader = await _unitOfWork.TerminalReaders.GetByIdAsync(readerId);
|
||||||
|
if (reader == null || reader.CompanyId != companyId || reader.Status != TerminalReaderStatus.Active)
|
||||||
|
return Json(new { success = false, error = "Card reader not found." });
|
||||||
|
|
||||||
|
// In-person surcharge is OFF unless the shop has explicitly enabled it (compliance varies by state).
|
||||||
|
var surcharge = company.TerminalSurchargeEnabled ? CalculateSurcharge(amount, company) : 0m;
|
||||||
|
|
||||||
|
var (ok, paymentIntentId, error) = await _stripeConnect.ProcessInvoicePaymentOnReaderAsync(
|
||||||
|
company.StripeAccountId!, reader.StripeReaderId, amount, surcharge, "usd", invoice.InvoiceNumber, invoice.Id);
|
||||||
|
if (!ok)
|
||||||
|
return Json(new { success = false, error });
|
||||||
|
|
||||||
|
// Persist the PI id so HandlePaymentSucceededAsync's idempotency guard matches (mirrors PaymentController.CreateIntent).
|
||||||
|
invoice.StripePaymentIntentId = paymentIntentId;
|
||||||
|
if (invoice.OnlinePaymentStatus == OnlinePaymentStatus.NotApplicable)
|
||||||
|
invoice.OnlinePaymentStatus = OnlinePaymentStatus.Pending;
|
||||||
|
invoice.UpdatedAt = DateTime.UtcNow;
|
||||||
|
await _unitOfWork.Invoices.UpdateAsync(invoice);
|
||||||
|
await _unitOfWork.CompleteAsync();
|
||||||
|
|
||||||
|
return Json(new { success = true, paymentIntentId });
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Polls the reader's action status for live UI feedback and reports whether the webhook has
|
||||||
|
/// already recorded the payment (derived from the invoice's online payment status for this PI).
|
||||||
|
/// </summary>
|
||||||
|
[HttpGet]
|
||||||
|
public async Task<IActionResult> PaymentStatus(int readerId, string paymentIntentId)
|
||||||
|
{
|
||||||
|
var companyId = CompanyId;
|
||||||
|
|
||||||
|
var reader = await _unitOfWork.TerminalReaders.GetByIdAsync(readerId);
|
||||||
|
if (reader == null || reader.CompanyId != companyId)
|
||||||
|
return Json(new { success = false, error = "Card reader not found." });
|
||||||
|
|
||||||
|
var company = await _unitOfWork.Companies.GetByIdAsync(companyId);
|
||||||
|
if (company?.StripeAccountId == null)
|
||||||
|
return Json(new { success = false, error = "Stripe is not connected." });
|
||||||
|
|
||||||
|
var status = await _stripeConnect.GetReaderStatusAsync(company.StripeAccountId, reader.StripeReaderId);
|
||||||
|
|
||||||
|
// The webhook is the source of truth — check whether it has landed for this PaymentIntent.
|
||||||
|
var invoice = (await _unitOfWork.Invoices.FindAsync(
|
||||||
|
i => i.CompanyId == companyId && i.StripePaymentIntentId == paymentIntentId)).FirstOrDefault();
|
||||||
|
var webhookRecorded = invoice != null
|
||||||
|
&& invoice.OnlinePaymentStatus is OnlinePaymentStatus.Paid or OnlinePaymentStatus.PartiallyPaid;
|
||||||
|
|
||||||
|
return Json(new
|
||||||
|
{
|
||||||
|
success = status.Success,
|
||||||
|
actionStatus = status.ActionStatus,
|
||||||
|
failureCode = status.FailureCode,
|
||||||
|
failureMessage = status.FailureMessage,
|
||||||
|
webhookRecorded,
|
||||||
|
error = status.ErrorMessage
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Cancels an in-progress reader action (clerk cancelled or wants to retry).</summary>
|
||||||
|
[HttpPost, ValidateAntiForgeryToken]
|
||||||
|
public async Task<IActionResult> CancelPayment(int readerId)
|
||||||
|
{
|
||||||
|
var companyId = CompanyId;
|
||||||
|
var reader = await _unitOfWork.TerminalReaders.GetByIdAsync(readerId);
|
||||||
|
if (reader == null || reader.CompanyId != companyId)
|
||||||
|
return Json(new { success = false, error = "Card reader not found." });
|
||||||
|
|
||||||
|
var company = await _unitOfWork.Companies.GetByIdAsync(companyId);
|
||||||
|
if (company?.StripeAccountId == null)
|
||||||
|
return Json(new { success = false, error = "Stripe is not connected." });
|
||||||
|
|
||||||
|
var (ok, error) = await _stripeConnect.CancelReaderActionAsync(company.StripeAccountId, reader.StripeReaderId);
|
||||||
|
return Json(new { success = ok, error });
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// TEST MODE ONLY: simulates a card tap on a simulated reader so a payment can complete without
|
||||||
|
/// hardware. Returns 404 in production so the endpoint cannot be probed there.
|
||||||
|
/// </summary>
|
||||||
|
[HttpPost, ValidateAntiForgeryToken]
|
||||||
|
public async Task<IActionResult> SimulateTap(int readerId)
|
||||||
|
{
|
||||||
|
if (!IsTestMode)
|
||||||
|
return NotFound();
|
||||||
|
|
||||||
|
var companyId = CompanyId;
|
||||||
|
var reader = await _unitOfWork.TerminalReaders.GetByIdAsync(readerId);
|
||||||
|
if (reader == null || reader.CompanyId != companyId)
|
||||||
|
return Json(new { success = false, error = "Card reader not found." });
|
||||||
|
|
||||||
|
var company = await _unitOfWork.Companies.GetByIdAsync(companyId);
|
||||||
|
if (company?.StripeAccountId == null)
|
||||||
|
return Json(new { success = false, error = "Stripe is not connected." });
|
||||||
|
|
||||||
|
var (ok, error) = await _stripeConnect.SimulatePresentPaymentMethodAsync(company.StripeAccountId, reader.StripeReaderId);
|
||||||
|
return Json(new { success = ok, error });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== Helpers =====
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Ensures the company has a Stripe Terminal Location, creating one from its address if needed and
|
||||||
|
/// persisting the id. Returns the location id to attach readers to.
|
||||||
|
/// </summary>
|
||||||
|
private async Task<(bool Success, string? LocationId, string? Error)> EnsureLocationAsync(Company company)
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrEmpty(company.StripeTerminalLocationId))
|
||||||
|
return (true, company.StripeTerminalLocationId, null);
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(company.Address) || string.IsNullOrWhiteSpace(company.City)
|
||||||
|
|| string.IsNullOrWhiteSpace(company.State) || string.IsNullOrWhiteSpace(company.ZipCode))
|
||||||
|
{
|
||||||
|
return (false, null, "Complete your company address (street, city, state, ZIP) before registering a reader.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var address = new TerminalAddressDto
|
||||||
|
{
|
||||||
|
Line1 = company.Address!,
|
||||||
|
City = company.City!,
|
||||||
|
State = company.State!,
|
||||||
|
PostalCode = company.ZipCode!,
|
||||||
|
Country = "US"
|
||||||
|
};
|
||||||
|
|
||||||
|
var (ok, locationId, error) = await _stripeConnect.CreateTerminalLocationAsync(
|
||||||
|
company.StripeAccountId!, company.CompanyName, address);
|
||||||
|
if (!ok)
|
||||||
|
return (false, null, error);
|
||||||
|
|
||||||
|
company.StripeTerminalLocationId = locationId;
|
||||||
|
company.UpdatedAt = DateTime.UtcNow;
|
||||||
|
await _unitOfWork.Companies.UpdateAsync(company);
|
||||||
|
await _unitOfWork.CompleteAsync();
|
||||||
|
|
||||||
|
return (true, locationId, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Mirrors the online surcharge calculation (percent/flat) used by PaymentController.</summary>
|
||||||
|
private static decimal CalculateSurcharge(decimal amount, Company company)
|
||||||
|
{
|
||||||
|
return company.OnlinePaymentSurchargeType switch
|
||||||
|
{
|
||||||
|
OnlinePaymentSurchargeType.Percent => Math.Round(amount * (company.OnlinePaymentSurchargeValue / 100m), 2),
|
||||||
|
OnlinePaymentSurchargeType.Flat => company.OnlinePaymentSurchargeValue,
|
||||||
|
_ => 0m
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Projects a reader to the anonymous shape returned to the settings tab JS.</summary>
|
||||||
|
private static object ToJson(TerminalReader r) => new
|
||||||
|
{
|
||||||
|
id = r.Id,
|
||||||
|
label = r.Label,
|
||||||
|
deviceType = r.DeviceType,
|
||||||
|
serialNumber = r.SerialNumber,
|
||||||
|
networkStatus = r.LastKnownNetworkStatus,
|
||||||
|
lastSeenAt = r.LastSeenAt
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -426,6 +426,14 @@ public static class HelpKnowledgeBase
|
|||||||
- Payment is recorded automatically on the invoice and the invoice status updates to Paid or Partially Paid
|
- Payment is recorded automatically on the invoice and the invoice status updates to Paid or Partially Paid
|
||||||
- The company receives a bell notification: "Online Payment Received"
|
- The company receives a bell notification: "Online Payment Received"
|
||||||
|
|
||||||
|
**In-person card payments (Stripe Terminal — WisePOS E):**
|
||||||
|
Take a card payment in person against an invoice using a Stripe Terminal **WisePOS E** card reader. This is included with the same plan entitlement as online payments and runs on the same connected Stripe account.
|
||||||
|
- **Setup (one-time):** Go to **Settings → Card Readers** (the tab appears once Stripe is connected). On the reader, open **Settings → Generate registration code** to get a three-word code, enter it with a label (e.g. "Front Counter") and click **Add Reader**.
|
||||||
|
- **Taking a payment:** On an invoice with a balance due, click **Take Card Payment**, pick the reader, confirm the amount, and click **Send to Reader**. The reader prompts the customer to tap, insert, or swipe. The screen shows live progress and refreshes the invoice when approved.
|
||||||
|
- The payment is recorded automatically (method: **Card Reader**) by the same Stripe webhook used for online payments — it posts to your books and advances the invoice to Paid/Partially Paid. Partial payments are supported.
|
||||||
|
- **In-person surcharge** is OFF by default. It can be enabled on the Card Readers tab, but in-person surcharging is regulated differently than online and is prohibited in some states — only enable it after confirming local rules.
|
||||||
|
- **Declines/cancellations** show an error on the reader and in the app; nothing is charged. Refunds use the normal Issue Refund flow.
|
||||||
|
|
||||||
**Voiding an invoice:** Invoice Details → "Void" — marks it voided. Cannot void a paid invoice.
|
**Voiding an invoice:** Invoice Details → "Void" — marks it voided. Cannot void a paid invoice.
|
||||||
|
|
||||||
**Refunds:** Issue a refund from Invoice Details → "Issue Refund."
|
**Refunds:** Issue a refund from Invoice Details → "Issue Refund."
|
||||||
|
|||||||
@@ -41,6 +41,7 @@
|
|||||||
@if (Model.AllowOnlinePayments)
|
@if (Model.AllowOnlinePayments)
|
||||||
{
|
{
|
||||||
<option value="online-payments">Online Payments</option>
|
<option value="online-payments">Online Payments</option>
|
||||||
|
<option value="card-readers">Card Readers</option>
|
||||||
}
|
}
|
||||||
<option value="kiosk">Kiosk</option>
|
<option value="kiosk">Kiosk</option>
|
||||||
<option value="timeclock">Timeclock</option>
|
<option value="timeclock">Timeclock</option>
|
||||||
@@ -113,6 +114,11 @@
|
|||||||
<i class="bi bi-credit-card"></i> Online Payments
|
<i class="bi bi-credit-card"></i> Online Payments
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
|
<li class="nav-item" role="presentation">
|
||||||
|
<button class="nav-link" id="card-readers-tab" data-bs-toggle="tab" data-bs-target="#card-readers" type="button" role="tab">
|
||||||
|
<i class="bi bi-credit-card-2-front"></i> Card Readers
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
}
|
}
|
||||||
<li class="nav-item" role="presentation">
|
<li class="nav-item" role="presentation">
|
||||||
<button class="nav-link" id="kiosk-tab" data-bs-toggle="tab" data-bs-target="#kiosk" type="button" role="tab">
|
<button class="nav-link" id="kiosk-tab" data-bs-toggle="tab" data-bs-target="#kiosk" type="button" role="tab">
|
||||||
@@ -2018,6 +2024,102 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Card Readers (Stripe Terminal) Tab -->
|
||||||
|
<div class="tab-pane fade" id="card-readers" role="tabpanel">
|
||||||
|
<div class="card mt-3">
|
||||||
|
<div class="card-header">
|
||||||
|
<h5 class="mb-0"><i class="bi bi-credit-card-2-front me-2"></i>Card Readers (In-Person Payments)</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
@if (Model.StripeConnectStatus != PowderCoating.Core.Enums.StripeConnectStatus.Active)
|
||||||
|
{
|
||||||
|
<div class="alert alert-warning alert-permanent d-flex align-items-center gap-3 mb-0">
|
||||||
|
<i class="bi bi-exclamation-triangle-fill fs-4"></i>
|
||||||
|
<div>
|
||||||
|
<strong>Connect Stripe first.</strong>
|
||||||
|
<p class="mb-0 small">Card readers run on your connected Stripe account. Connect it on the
|
||||||
|
<strong>Online Payments</strong> tab, then come back to register a WisePOS 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 -->
|
<!-- Kiosk Tab -->
|
||||||
@@ -3843,6 +3945,10 @@
|
|||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
|
@if (Model.AllowOnlinePayments && Model.StripeConnectStatus == PowderCoating.Core.Enums.StripeConnectStatus.Active)
|
||||||
|
{
|
||||||
|
<script src="~/js/terminal-readers.js" asp-append-version="true"></script>
|
||||||
|
}
|
||||||
<script src="~/js/company-settings-custom-formulas.js" asp-append-version="true"></script>
|
<script src="~/js/company-settings-custom-formulas.js" asp-append-version="true"></script>
|
||||||
<script>
|
<script>
|
||||||
// Load formula templates when the tab is first shown; auto-show walkthrough if no templates yet
|
// Load formula templates when the tab is first shown; auto-show walkthrough if no templates yet
|
||||||
|
|||||||
@@ -360,6 +360,33 @@
|
|||||||
The link is unique to each invoice and does not expire as long as the invoice remains unpaid.
|
The link is unique to each invoice and does not expire as long as the invoice remains unpaid.
|
||||||
Voided invoices do not generate payment links.
|
Voided invoices do not generate payment links.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
<h3 id="card-readers" class="h6 fw-semibold mt-4 mb-2">In-Person Card Payments (WisePOS 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>
|
||||||
|
|
||||||
<section id="payment-reminders" class="mb-5">
|
<section id="payment-reminders" class="mb-5">
|
||||||
@@ -415,6 +442,7 @@
|
|||||||
<a class="nav-link py-1 px-3 small text-body" href="#deposits">Deposits</a>
|
<a class="nav-link py-1 px-3 small text-body" href="#deposits">Deposits</a>
|
||||||
<a class="nav-link py-1 px-3 small text-body" href="#gift-certificates">Gift Certificates</a>
|
<a class="nav-link py-1 px-3 small text-body" href="#gift-certificates">Gift Certificates</a>
|
||||||
<a class="nav-link py-1 px-3 small text-body" href="#online-payments">Online Payments</a>
|
<a class="nav-link py-1 px-3 small text-body" href="#online-payments">Online Payments</a>
|
||||||
|
<a class="nav-link py-1 px-3 small text-body" href="#card-readers">In-Person Card Payments</a>
|
||||||
<a class="nav-link py-1 px-3 small text-body" href="#payment-reminders">Payment Reminders</a>
|
<a class="nav-link py-1 px-3 small text-body" href="#payment-reminders">Payment Reminders</a>
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -644,6 +644,12 @@
|
|||||||
<button type="button" class="btn btn-success" data-bs-toggle="modal" data-bs-target="#recordPaymentModal">
|
<button type="button" class="btn btn-success" data-bs-toggle="modal" data-bs-target="#recordPaymentModal">
|
||||||
<i class="bi bi-cash me-2"></i>Record Payment
|
<i class="bi bi-cash me-2"></i>Record Payment
|
||||||
</button>
|
</button>
|
||||||
|
@if ((bool)(ViewBag.TerminalPaymentsEnabled ?? false))
|
||||||
|
{
|
||||||
|
<button type="button" class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#cardReaderModal">
|
||||||
|
<i class="bi bi-credit-card-2-front me-2"></i>Take Card Payment
|
||||||
|
</button>
|
||||||
|
}
|
||||||
}
|
}
|
||||||
<a asp-action="DownloadPdf" asp-route-id="@Model.Id" asp-route-inline="true"
|
<a asp-action="DownloadPdf" asp-route-id="@Model.Id" asp-route-inline="true"
|
||||||
class="btn btn-outline-secondary" target="_blank" rel="noopener">
|
class="btn btn-outline-secondary" target="_blank" rel="noopener">
|
||||||
@@ -1042,6 +1048,67 @@
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@if (canPay && (bool)(ViewBag.TerminalPaymentsEnabled ?? false))
|
||||||
|
{
|
||||||
|
var terminalReaders = ViewBag.TerminalReaders as IEnumerable<SelectListItem> ?? Enumerable.Empty<SelectListItem>();
|
||||||
|
<!-- Take Card Payment (Stripe Terminal) Modal -->
|
||||||
|
<div class="modal fade" id="cardReaderModal" tabindex="-1"
|
||||||
|
data-invoice-id="@Model.Id"
|
||||||
|
data-balance-due="@Model.BalanceDue.ToString("F2")"
|
||||||
|
data-test-mode="@((bool)(ViewBag.TerminalTestMode ?? false) ? "true" : "false")">
|
||||||
|
<div class="modal-dialog">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title"><i class="bi bi-credit-card-2-front me-2"></i>Take Card Payment</h5>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
@* Setup view: choose reader + amount, then send to the reader *@
|
||||||
|
<div id="cardReaderSetup">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label fw-semibold" for="cardReaderSelect">Card Reader</label>
|
||||||
|
<select id="cardReaderSelect" class="form-select">
|
||||||
|
@foreach (var r in terminalReaders)
|
||||||
|
{
|
||||||
|
<option value="@r.Value">@r.Text</option>
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label fw-semibold" for="cardReaderAmount">Amount <span class="text-danger">*</span></label>
|
||||||
|
<div class="input-group">
|
||||||
|
<span class="input-group-text">$</span>
|
||||||
|
<input type="number" id="cardReaderAmount" class="form-control" step="0.01" min="0.01"
|
||||||
|
max="@Model.BalanceDue" value="@Model.BalanceDue.ToString("F2")" />
|
||||||
|
</div>
|
||||||
|
<div class="form-text">Balance due: @Model.BalanceDue.ToString("C")</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@* Status view: live progress while the customer presents their card *@
|
||||||
|
<div id="cardReaderStatus" class="text-center py-4 d-none">
|
||||||
|
<div id="cardReaderSpinner" class="spinner-border text-primary mb-3" role="status"></div>
|
||||||
|
<div id="cardReaderStatusText" class="fw-semibold"></div>
|
||||||
|
<div id="cardReaderStatusSub" class="text-muted small mt-1"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" id="cardReaderCancelBtn" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||||
|
@if ((bool)(ViewBag.TerminalTestMode ?? false))
|
||||||
|
{
|
||||||
|
<button type="button" id="cardReaderSimulateBtn" class="btn btn-outline-info d-none" title="Test mode only">
|
||||||
|
<i class="bi bi-magic me-1"></i>Simulate Tap
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
<button type="button" id="cardReaderProcessBtn" class="btn btn-primary">
|
||||||
|
<i class="bi bi-send me-2"></i>Send to Reader
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
<!-- Edit Payment Modal -->
|
<!-- Edit Payment Modal -->
|
||||||
@if (!isVoided)
|
@if (!isVoided)
|
||||||
{
|
{
|
||||||
@@ -1531,6 +1598,18 @@
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
@if (canPay && (bool)(ViewBag.TerminalPaymentsEnabled ?? false))
|
||||||
|
{
|
||||||
|
<script>
|
||||||
|
window.terminalPayment = {
|
||||||
|
processUrl: '@Url.Action("ProcessPayment", "Terminal")',
|
||||||
|
statusUrl: '@Url.Action("PaymentStatus", "Terminal")',
|
||||||
|
cancelUrl: '@Url.Action("CancelPayment", "Terminal")',
|
||||||
|
simulateUrl: '@Url.Action("SimulateTap", "Terminal")'
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
<script src="~/js/terminal-payment.js" asp-append-version="true"></script>
|
||||||
|
}
|
||||||
<script>
|
<script>
|
||||||
function submitSendInvoice(sendEmail, sendSms) {
|
function submitSendInvoice(sendEmail, sendSms) {
|
||||||
document.getElementById('sendInvoiceSendEmail').value = sendEmail ? 'true' : 'false';
|
document.getElementById('sendInvoiceSendEmail').value = sendEmail ? 'true' : 'false';
|
||||||
|
|||||||
@@ -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,
|
CreateTenantContext().Object,
|
||||||
Mock.Of<INotificationService>(),
|
Mock.Of<INotificationService>(),
|
||||||
Mock.Of<IAccountBalanceService>(),
|
Mock.Of<IAccountBalanceService>(),
|
||||||
Mock.Of<ICompanyLogoService>());
|
Mock.Of<ICompanyLogoService>(),
|
||||||
|
new Microsoft.Extensions.Configuration.ConfigurationBuilder().Build());
|
||||||
|
|
||||||
var identity = new ClaimsIdentity([new Claim(ClaimTypes.Role, "SuperAdmin")], "Test");
|
var identity = new ClaimsIdentity([new Claim(ClaimTypes.Role, "SuperAdmin")], "Test");
|
||||||
var principal = new ClaimsPrincipal(identity);
|
var principal = new ClaimsPrincipal(identity);
|
||||||
|
|||||||
@@ -0,0 +1,232 @@
|
|||||||
|
using System.Security.Claims;
|
||||||
|
using System.Text.Json;
|
||||||
|
using Microsoft.AspNetCore.Http;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.Configuration;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using Moq;
|
||||||
|
using PowderCoating.Application.Interfaces;
|
||||||
|
using PowderCoating.Core.Entities;
|
||||||
|
using PowderCoating.Core.Enums;
|
||||||
|
using PowderCoating.Core.Interfaces;
|
||||||
|
using PowderCoating.Infrastructure.Data;
|
||||||
|
using PowderCoating.Infrastructure.Repositories;
|
||||||
|
using PowderCoating.Web.Controllers;
|
||||||
|
|
||||||
|
namespace PowderCoating.UnitTests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Validation and surcharge-routing tests for the in-person Stripe Terminal payment flow.
|
||||||
|
/// The Stripe API itself is mocked via <see cref="IStripeConnectService"/>; these tests cover the
|
||||||
|
/// controller's guard rails and the TerminalSurchargeEnabled toggle, not Stripe behavior.
|
||||||
|
/// </summary>
|
||||||
|
public class TerminalControllerTests
|
||||||
|
{
|
||||||
|
private const int CompanyId = 1;
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ProcessPayment_WhenAmountExceedsBalance_ReturnsError()
|
||||||
|
{
|
||||||
|
await using var context = CreateContext();
|
||||||
|
SeedCompany(context, connected: true);
|
||||||
|
SeedInvoice(context, total: 100m, amountPaid: 0m);
|
||||||
|
SeedReader(context);
|
||||||
|
await context.SaveChangesAsync();
|
||||||
|
|
||||||
|
var stripe = new Mock<IStripeConnectService>();
|
||||||
|
var controller = CreateController(context, stripe);
|
||||||
|
|
||||||
|
var result = await controller.ProcessPayment(invoiceId: 1, readerId: 1, amount: 250m);
|
||||||
|
|
||||||
|
using var doc = ParseJson(result);
|
||||||
|
Assert.False(doc.RootElement.GetProperty("success").GetBoolean());
|
||||||
|
stripe.Verify(s => s.ProcessInvoicePaymentOnReaderAsync(
|
||||||
|
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<decimal>(), It.IsAny<decimal>(),
|
||||||
|
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<int>()), Times.Never);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ProcessPayment_WhenInvoiceVoided_ReturnsError()
|
||||||
|
{
|
||||||
|
await using var context = CreateContext();
|
||||||
|
SeedCompany(context, connected: true);
|
||||||
|
SeedInvoice(context, total: 100m, amountPaid: 0m, status: InvoiceStatus.Voided);
|
||||||
|
SeedReader(context);
|
||||||
|
await context.SaveChangesAsync();
|
||||||
|
|
||||||
|
var stripe = new Mock<IStripeConnectService>();
|
||||||
|
var controller = CreateController(context, stripe);
|
||||||
|
|
||||||
|
var result = await controller.ProcessPayment(invoiceId: 1, readerId: 1, amount: 50m);
|
||||||
|
|
||||||
|
using var doc = ParseJson(result);
|
||||||
|
Assert.False(doc.RootElement.GetProperty("success").GetBoolean());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ProcessPayment_WhenStripeNotConnected_ReturnsError()
|
||||||
|
{
|
||||||
|
await using var context = CreateContext();
|
||||||
|
SeedCompany(context, connected: false);
|
||||||
|
SeedInvoice(context, total: 100m, amountPaid: 0m);
|
||||||
|
SeedReader(context);
|
||||||
|
await context.SaveChangesAsync();
|
||||||
|
|
||||||
|
var stripe = new Mock<IStripeConnectService>();
|
||||||
|
var controller = CreateController(context, stripe);
|
||||||
|
|
||||||
|
var result = await controller.ProcessPayment(invoiceId: 1, readerId: 1, amount: 50m);
|
||||||
|
|
||||||
|
using var doc = ParseJson(result);
|
||||||
|
Assert.False(doc.RootElement.GetProperty("success").GetBoolean());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ProcessPayment_WhenSurchargeDisabled_PassesZeroSurchargeAndStoresPaymentIntent()
|
||||||
|
{
|
||||||
|
await using var context = CreateContext();
|
||||||
|
SeedCompany(context, connected: true, surchargeEnabled: false, surchargePercent: 3m);
|
||||||
|
SeedInvoice(context, total: 100m, amountPaid: 0m);
|
||||||
|
SeedReader(context);
|
||||||
|
await context.SaveChangesAsync();
|
||||||
|
|
||||||
|
var stripe = new Mock<IStripeConnectService>();
|
||||||
|
stripe.Setup(s => s.ProcessInvoicePaymentOnReaderAsync(
|
||||||
|
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<decimal>(), It.IsAny<decimal>(),
|
||||||
|
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<int>()))
|
||||||
|
.ReturnsAsync((true, "pi_123", (string?)null));
|
||||||
|
var controller = CreateController(context, stripe);
|
||||||
|
|
||||||
|
var result = await controller.ProcessPayment(invoiceId: 1, readerId: 1, amount: 100m);
|
||||||
|
|
||||||
|
using var doc = ParseJson(result);
|
||||||
|
Assert.True(doc.RootElement.GetProperty("success").GetBoolean());
|
||||||
|
Assert.Equal("pi_123", doc.RootElement.GetProperty("paymentIntentId").GetString());
|
||||||
|
|
||||||
|
// Surcharge must be 0 when the toggle is off, even though the company has a 3% online fee.
|
||||||
|
stripe.Verify(s => s.ProcessInvoicePaymentOnReaderAsync(
|
||||||
|
"acct_test", "tmr_test", 100m, 0m, "usd", "INV-1", 1), Times.Once);
|
||||||
|
|
||||||
|
// The PaymentIntent id must be persisted on the invoice for the webhook idempotency guard.
|
||||||
|
var invoice = await context.Invoices.IgnoreQueryFilters().SingleAsync();
|
||||||
|
Assert.Equal("pi_123", invoice.StripePaymentIntentId);
|
||||||
|
Assert.Equal(OnlinePaymentStatus.Pending, invoice.OnlinePaymentStatus);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ProcessPayment_WhenSurchargeEnabled_PassesComputedSurcharge()
|
||||||
|
{
|
||||||
|
await using var context = CreateContext();
|
||||||
|
SeedCompany(context, connected: true, surchargeEnabled: true, surchargePercent: 3m);
|
||||||
|
SeedInvoice(context, total: 200m, amountPaid: 0m);
|
||||||
|
SeedReader(context);
|
||||||
|
await context.SaveChangesAsync();
|
||||||
|
|
||||||
|
var stripe = new Mock<IStripeConnectService>();
|
||||||
|
stripe.Setup(s => s.ProcessInvoicePaymentOnReaderAsync(
|
||||||
|
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<decimal>(), It.IsAny<decimal>(),
|
||||||
|
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<int>()))
|
||||||
|
.ReturnsAsync((true, "pi_456", (string?)null));
|
||||||
|
var controller = CreateController(context, stripe);
|
||||||
|
|
||||||
|
await controller.ProcessPayment(invoiceId: 1, readerId: 1, amount: 200m);
|
||||||
|
|
||||||
|
// 3% of 200 = 6.00
|
||||||
|
stripe.Verify(s => s.ProcessInvoicePaymentOnReaderAsync(
|
||||||
|
"acct_test", "tmr_test", 200m, 6m, "usd", "INV-1", 1), Times.Once);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Helpers ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private static TerminalController CreateController(ApplicationDbContext context, Mock<IStripeConnectService> stripe)
|
||||||
|
{
|
||||||
|
var uow = new UnitOfWork(context);
|
||||||
|
var tenant = new Mock<ITenantContext>();
|
||||||
|
tenant.Setup(t => t.GetCurrentCompanyId()).Returns(CompanyId);
|
||||||
|
|
||||||
|
var config = new ConfigurationBuilder()
|
||||||
|
.AddInMemoryCollection(new Dictionary<string, string?> { ["Stripe:Connect:SecretKey"] = "sk_test_abc" })
|
||||||
|
.Build();
|
||||||
|
|
||||||
|
var controller = new TerminalController(
|
||||||
|
uow, stripe.Object, tenant.Object, config, Mock.Of<ILogger<TerminalController>>())
|
||||||
|
{
|
||||||
|
ControllerContext = new() { HttpContext = new DefaultHttpContext() }
|
||||||
|
};
|
||||||
|
return controller;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void SeedCompany(ApplicationDbContext context, bool connected,
|
||||||
|
bool surchargeEnabled = false, decimal surchargePercent = 0m)
|
||||||
|
{
|
||||||
|
context.Companies.Add(new Company
|
||||||
|
{
|
||||||
|
Id = CompanyId,
|
||||||
|
CompanyName = "Test Shop",
|
||||||
|
StripeAccountId = connected ? "acct_test" : null,
|
||||||
|
StripeConnectStatus = connected ? StripeConnectStatus.Active : StripeConnectStatus.NotConnected,
|
||||||
|
StripeTerminalLocationId = "tml_test",
|
||||||
|
TerminalSurchargeEnabled = surchargeEnabled,
|
||||||
|
OnlinePaymentSurchargeType = surchargePercent > 0 ? OnlinePaymentSurchargeType.Percent : OnlinePaymentSurchargeType.None,
|
||||||
|
OnlinePaymentSurchargeValue = surchargePercent
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void SeedInvoice(ApplicationDbContext context, decimal total, decimal amountPaid,
|
||||||
|
InvoiceStatus status = InvoiceStatus.Sent)
|
||||||
|
{
|
||||||
|
context.Invoices.Add(new Invoice
|
||||||
|
{
|
||||||
|
Id = 1,
|
||||||
|
CompanyId = CompanyId,
|
||||||
|
InvoiceNumber = "INV-1",
|
||||||
|
Total = total,
|
||||||
|
AmountPaid = amountPaid,
|
||||||
|
Status = status
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void SeedReader(ApplicationDbContext context)
|
||||||
|
{
|
||||||
|
context.TerminalReaders.Add(new TerminalReader
|
||||||
|
{
|
||||||
|
Id = 1,
|
||||||
|
CompanyId = CompanyId,
|
||||||
|
StripeReaderId = "tmr_test",
|
||||||
|
StripeLocationId = "tml_test",
|
||||||
|
Label = "Front Counter",
|
||||||
|
DeviceType = "simulated_wisepos_e",
|
||||||
|
Status = TerminalReaderStatus.Active
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private static JsonDocument ParseJson(Microsoft.AspNetCore.Mvc.IActionResult result)
|
||||||
|
{
|
||||||
|
var json = Assert.IsType<Microsoft.AspNetCore.Mvc.JsonResult>(result);
|
||||||
|
return JsonDocument.Parse(JsonSerializer.Serialize(json.Value));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ApplicationDbContext CreateContext()
|
||||||
|
{
|
||||||
|
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
|
||||||
|
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
||||||
|
.Options;
|
||||||
|
|
||||||
|
// SuperAdmin principal so IsPlatformAdmin = true and the tenant query filter is bypassed in-test.
|
||||||
|
var identity = new ClaimsIdentity(new[] { new Claim(ClaimTypes.Role, "SuperAdmin") }, "Test");
|
||||||
|
var principal = new ClaimsPrincipal(identity);
|
||||||
|
|
||||||
|
byte[]? noBytes = null;
|
||||||
|
var sessionMock = new Mock<ISession>();
|
||||||
|
sessionMock.Setup(s => s.TryGetValue(It.IsAny<string>(), out noBytes)).Returns(false);
|
||||||
|
|
||||||
|
var httpContextMock = new Mock<HttpContext>();
|
||||||
|
httpContextMock.SetupGet(c => c.User).Returns(principal);
|
||||||
|
httpContextMock.SetupGet(c => c.Session).Returns(sessionMock.Object);
|
||||||
|
|
||||||
|
var accessor = new Mock<IHttpContextAccessor>();
|
||||||
|
accessor.SetupGet(a => a.HttpContext).Returns(httpContextMock.Object);
|
||||||
|
|
||||||
|
return new ApplicationDbContext(options, accessor.Object, null!);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user