Files
PowderCoatingLogix/src/PowderCoating.Web/Controllers/PaymentController.cs
T
spouliot 33277de727 Payment hardening: InvariantCulture on JS literals, remove dead CustomerEmail
Razor numeric expressions emitted into JS literals (MAX_TOTAL,
SURCHARGE_VALUE) now use InvariantCulture, matching the pattern already
used on the deposit page. Without this, a server culture with comma
decimal separators would silently truncate values like 2.5% to 2.

CustomerEmail removed from PaymentPageViewModel and
DepositPaymentPageViewModel — it was populated from the DB on every
payment page load but never consumed after receipt_email was removed
from the Stripe PaymentIntent.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 13:20:47 -04:00

1008 lines
47 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using PowderCoating.Application.Interfaces;
using PowderCoating.Core.Enums;
using PowderCoating.Core.Interfaces;
using PowderCoating.Infrastructure.Data;
using PowderCoating.Shared.Constants;
using Stripe;
using AccountSubTypeEnum = PowderCoating.Core.Enums.AccountSubType;
namespace PowderCoating.Web.Controllers;
/// <summary>
/// Public (unauthenticated) controller for customer-facing invoice payment pages.
/// All actions here are accessible without login — security is enforced via signed tokens.
/// </summary>
[AllowAnonymous]
[EnableRateLimiting(AppConstants.RateLimitPolicies.Public)]
public class PaymentController : Controller
{
private readonly ApplicationDbContext _context;
private readonly IStripeConnectService _stripeConnect;
private readonly INotificationService _notificationService;
private readonly IInAppNotificationService _inApp;
private readonly IConfiguration _configuration;
private readonly ILogger<PaymentController> _logger;
private readonly IAccountBalanceService _accountBalanceService;
public PaymentController(
ApplicationDbContext context,
IStripeConnectService stripeConnect,
INotificationService notificationService,
IInAppNotificationService inApp,
IConfiguration configuration,
ILogger<PaymentController> logger,
IAccountBalanceService accountBalanceService)
{
_context = context;
_stripeConnect = stripeConnect;
_notificationService = notificationService;
_inApp = inApp;
_configuration = configuration;
_logger = logger;
_accountBalanceService = accountBalanceService;
}
// ─── GET /pay/{token} ────────────────────────────────────────────────────
/// <summary>
/// Renders the customer-facing invoice payment page. Resolves the invoice from the GUID token
/// embedded in the email link, validates that the token is not expired and the invoice is still
/// unpaid, then builds the view model with surcharge details and the Stripe publishable key.
/// Uses <see cref="ResolveInvoiceFromTokenAsync"/> so validation logic is shared with
/// <see cref="CreateIntent"/>.
/// </summary>
[HttpGet("/pay/{token}")]
public async Task<IActionResult> Index(string token)
{
var (invoice, company, error) = await ResolveInvoiceFromTokenAsync(token);
if (error != null) return View("PaymentError", error);
var customer = await _context.Customers.AsNoTracking()
.FirstOrDefaultAsync(c => c.Id == invoice!.CustomerId);
var surcharge = CalculateSurcharge(invoice!.BalanceDue, company!);
var vm = new PaymentPageViewModel
{
InvoiceNumber = invoice!.InvoiceNumber,
InvoiceId = invoice.Id,
Token = token,
CustomerName = customer != null
? $"{customer.ContactFirstName} {customer.ContactLastName}".Trim()
: "Valued Customer",
CompanyName = company!.CompanyName,
BalanceDue = invoice.BalanceDue,
InvoiceTotal = invoice.Total,
AmountPaid = invoice.AmountPaid, // already includes online payments
SurchargeType = company.OnlinePaymentSurchargeType,
SurchargeValue = company.OnlinePaymentSurchargeValue,
SurchargeAmount = surcharge,
TotalWithSurcharge = invoice.BalanceDue + surcharge,
ExpiresAt = invoice.PaymentLinkExpiresAt!.Value,
IsFullyPaid = invoice.BalanceDue <= 0,
StripePublishableKey = _configuration["Stripe:Connect:PublishableKey"]!,
StripeAccountId = company.StripeAccountId!
};
return View(vm);
}
// ─── GET /pay/{token}/success ────────────────────────────────────────────
// Stripe redirects here after the customer completes payment in the browser.
/// <summary>
/// Renders the post-payment success page after Stripe redirects the customer back to the site.
/// The token is kept in the URL only to maintain URL consistency with the payment page — it is
/// not validated here because the payment result is authoritative via the webhook.
/// </summary>
[HttpGet("/pay/{token}/success")]
public IActionResult Success(string token) => View();
// ─── POST /pay/{token}/intent ─────────────────────────────────────────────
// Creates a PaymentIntent for the amount the customer chose to pay.
/// <summary>
/// Creates a Stripe PaymentIntent for a partial or full invoice payment. Re-validates the token
/// on every call so a shared or replayed link cannot be used after the invoice is fully paid or
/// expired. The surcharge (flat or percentage, configured per company) is added on top of the
/// requested amount and passed through to Stripe metadata so the webhook can strip it out when
/// recording <c>netPayment</c> against the invoice balance.
/// Sets <c>OnlinePaymentStatus = Pending</c> and saves the PaymentIntent ID to support idempotency
/// checks in <see cref="HandlePaymentSucceededAsync"/>.
/// </summary>
[HttpPost("/pay/{token}/intent")]
public async Task<IActionResult> CreateIntent(string token, [FromBody] CreateIntentRequest request)
{
var (invoice, company, error) = await ResolveInvoiceFromTokenAsync(token);
if (error != null) return BadRequest(new { error });
// Validate amount — must be > 0 and <= balance due
if (request.Amount <= 0 || request.Amount > invoice!.BalanceDue)
return BadRequest(new { error = "Invalid payment amount." });
var surcharge = CalculateSurcharge(request.Amount, company!);
var (success, clientSecret, paymentIntentId, stripeError) =
await _stripeConnect.CreatePaymentIntentAsync(
connectedAccountId: company!.StripeAccountId!,
invoiceTotal: request.Amount,
surchargeAmount: surcharge,
currency: "usd",
invoiceNumber: invoice.InvoiceNumber,
invoiceId: invoice.Id);
if (!success)
return BadRequest(new { error = stripeError });
// Store the latest PaymentIntent ID on the invoice
invoice.StripePaymentIntentId = paymentIntentId;
if (invoice.OnlinePaymentStatus == OnlinePaymentStatus.NotApplicable)
invoice.OnlinePaymentStatus = OnlinePaymentStatus.Pending;
_context.Update(invoice);
await _context.SaveChangesAsync();
return Ok(new { clientSecret, surchargeAmount = surcharge });
}
// ─── GET /invoice/{token} ────────────────────────────────────────────────
/// <summary>
/// Customer-facing read-only invoice view page. Resolved via PublicViewToken (permanent, no expiry).
/// Shows full line items, totals, and company branding. If a valid PaymentLinkToken exists, renders
/// a "Pay Now" button linking to /pay/{paymentLinkToken}. This is the link sent in SMS messages
/// since SMS cannot attach a PDF.
/// </summary>
[HttpGet("/invoice/{token}")]
public async Task<IActionResult> InvoiceView(string token)
{
try
{
var invoice = await _context.Invoices
.AsNoTracking()
.Include(i => i.InvoiceItems)
.Include(i => i.Customer)
.Include(i => i.Job)
.IgnoreQueryFilters()
.FirstOrDefaultAsync(i => i.PublicViewToken == token && !i.IsDeleted);
if (invoice == null)
return View("PaymentError", "This invoice link is invalid or has been removed.");
var company = await _context.Companies.AsNoTracking()
.IgnoreQueryFilters()
.FirstOrDefaultAsync(c => c.Id == invoice.CompanyId && !c.IsDeleted);
if (company == null)
return View("PaymentError", "Unable to load invoice details.");
var paymentUrl = (!string.IsNullOrEmpty(invoice.PaymentLinkToken)
&& invoice.PaymentLinkExpiresAt > DateTime.UtcNow
&& invoice.BalanceDue > 0)
? $"{Request.Scheme}://{Request.Host}/pay/{invoice.PaymentLinkToken}"
: null;
var vm = new InvoiceViewViewModel
{
InvoiceNumber = invoice.InvoiceNumber,
InvoiceDate = invoice.InvoiceDate,
DueDate = invoice.DueDate,
CustomerName = invoice.Customer != null
? $"{invoice.Customer.ContactFirstName} {invoice.Customer.ContactLastName}".Trim()
: "Valued Customer",
CompanyName = company.CompanyName,
CompanyPhone = company.Phone,
CompanyAddress = string.Join(", ", new[] { company.Address, company.City, company.State, company.ZipCode }
.Where(s => !string.IsNullOrWhiteSpace(s))),
LogoFilePath = company.LogoFilePath,
SubTotal = invoice.SubTotal,
TaxPercent = invoice.TaxPercent,
TaxAmount = invoice.TaxAmount,
DiscountAmount = invoice.DiscountAmount,
Total = invoice.Total,
AmountPaid = invoice.AmountPaid,
BalanceDue = invoice.BalanceDue,
Status = invoice.Status,
Notes = invoice.Notes,
Terms = invoice.Terms,
JobNumber = invoice.Job?.JobNumber,
PaymentUrl = paymentUrl,
LineItems = invoice.InvoiceItems.Select(i => new InvoiceViewLineItem
{
Description = i.Description,
Quantity = i.Quantity,
UnitPrice = i.UnitPrice,
TotalPrice = i.TotalPrice
}).ToList()
};
return View(vm);
}
catch (Exception ex)
{
_logger.LogError(ex, "InvoiceView failed for token {Token}", token);
return View("PaymentError", "An error occurred loading this invoice.");
}
}
// ─── GET /pay/deposit/{token} ────────────────────────────────────────────
/// <summary>
/// Renders the customer-facing deposit payment page for an approved quote. The deposit amount is
/// calculated as <c>Quote.Total × (DepositPercent / 100)</c>; the percentage is set by the company
/// when generating the quote link. Uses <see cref="ResolveDepositQuoteFromTokenAsync"/> to validate
/// the token and confirm the deposit has not already been paid.
/// </summary>
[HttpGet("/pay/deposit/{token}")]
public async Task<IActionResult> Deposit(string token)
{
var (quote, company, error) = await ResolveDepositQuoteFromTokenAsync(token);
if (error != null) return View("PaymentError", error);
var customer = quote!.Customer
?? await _context.Customers.AsNoTracking().FirstOrDefaultAsync(c => c.Id == quote.CustomerId);
var depositAmount = Math.Round(quote.Total * (quote.DepositPercent / 100m), 2);
var surcharge = CalculateSurcharge(depositAmount, company!);
var vm = new DepositPaymentPageViewModel
{
QuoteNumber = quote.QuoteNumber,
QuoteId = quote.Id,
Token = token,
CustomerName = customer != null
? $"{customer.ContactFirstName} {customer.ContactLastName}".Trim()
: quote.ProspectContactName ?? "Valued Customer",
CompanyName = company!.CompanyName,
DepositAmount = depositAmount,
QuoteTotal = quote.Total,
DepositPercent = quote.DepositPercent,
SurchargeType = company.OnlinePaymentSurchargeType,
SurchargeValue = company.OnlinePaymentSurchargeValue,
SurchargeAmount = surcharge,
TotalWithSurcharge = depositAmount + surcharge,
ExpiresAt = quote.DepositPaymentLinkExpiresAt!.Value,
StripePublishableKey = _configuration["Stripe:Connect:PublishableKey"]!,
StripeAccountId = company.StripeAccountId!
};
return View(vm);
}
// ─── POST /pay/deposit/{token}/intent ────────────────────────────────────
/// <summary>
/// Creates a Stripe PaymentIntent for a quote deposit payment. The deposit amount is always the
/// full computed deposit (not a partial amount chosen by the customer), so the <c>request.Amount</c>
/// parameter from the client is intentionally ignored in favour of the server-calculated value.
/// This prevents a malicious actor from sending an artificially low amount. The PaymentIntent ID
/// is stored on the quote immediately so <see cref="HandleDepositPaymentSucceededAsync"/> can
/// perform idempotency checks.
/// </summary>
[HttpPost("/pay/deposit/{token}/intent")]
public async Task<IActionResult> CreateDepositIntent(string token, [FromBody] CreateIntentRequest request)
{
var (quote, company, error) = await ResolveDepositQuoteFromTokenAsync(token);
if (error != null) return BadRequest(new { error });
var depositAmount = Math.Round(quote!.Total * (quote.DepositPercent / 100m), 2);
var surcharge = CalculateSurcharge(depositAmount, company!);
var (success, clientSecret, paymentIntentId, stripeError) =
await _stripeConnect.CreateDepositPaymentIntentAsync(
connectedAccountId: company!.StripeAccountId!,
depositAmount: depositAmount,
surchargeAmount: surcharge,
currency: "usd",
quoteNumber: quote.QuoteNumber,
quoteId: quote.Id);
if (!success)
return BadRequest(new { error = stripeError });
quote.DepositPaymentIntentId = paymentIntentId;
_context.Update(quote);
await _context.SaveChangesAsync();
return Ok(new { clientSecret, surchargeAmount = surcharge });
}
// ─── GET /pay/deposit/{token}/success ────────────────────────────────────
/// <summary>
/// Renders the post-deposit success page shown to the customer after Stripe redirects back.
/// Actual deposit recording happens in <see cref="HandleDepositPaymentSucceededAsync"/> via webhook,
/// not here — this is purely a user-facing confirmation screen.
/// </summary>
[HttpGet("/pay/deposit/{token}/success")]
public IActionResult DepositSuccess(string token) => View();
// ─── POST /stripe/connect-webhook ────────────────────────────────────────
// Stripe calls this when a payment on a connected account succeeds.
/// <summary>
/// Receives Stripe Connect account-level webhook events for payments made on connected
/// (tenant) Stripe accounts. This is distinct from the platform-level webhook handled by
/// <c>StripeWebhookController</c> which covers subscription billing events.
/// Verifies the <c>Stripe-Signature</c> header against the Connect webhook secret before
/// processing. Routes <c>payment_intent.succeeded</c> to either
/// <see cref="HandlePaymentSucceededAsync"/> (invoice) or
/// <see cref="HandleDepositPaymentSucceededAsync"/> (deposit) based on the <c>type</c> metadata
/// key set when the PaymentIntent was created. Handles refund and chargeback events too.
/// Returns HTTP 200 for all valid events — returning a non-2xx would cause Stripe to retry.
/// </summary>
[HttpPost("/stripe/connect-webhook")]
[IgnoreAntiforgeryToken]
public async Task<IActionResult> ConnectWebhook()
{
var json = await new StreamReader(HttpContext.Request.Body).ReadToEndAsync();
var webhookSecret = _configuration["Stripe:Connect:ConnectWebhookSecret"]!;
Event stripeEvent;
try
{
stripeEvent = EventUtility.ConstructEvent(
json, Request.Headers["Stripe-Signature"]!, webhookSecret);
}
catch (StripeException ex)
{
_logger.LogWarning("Invalid Stripe Connect webhook signature: {Message}", ex.Message);
return BadRequest();
}
if (stripeEvent.Type == "payment_intent.succeeded")
{
var intent = stripeEvent.Data.Object as PaymentIntent;
if (intent == null) return Ok();
// Route deposit vs invoice payments by metadata discriminator
intent.Metadata.TryGetValue("type", out var intentType);
if (intentType == "deposit")
await HandleDepositPaymentSucceededAsync(intent);
else
await HandlePaymentSucceededAsync(intent);
}
else if (stripeEvent.Type == "charge.refunded")
{
var charge = stripeEvent.Data.Object as Charge;
if (charge != null) await HandleChargeRefundedAsync(charge);
}
else if (stripeEvent.Type == "charge.dispute.created")
{
var dispute = stripeEvent.Data.Object as Dispute;
if (dispute != null) await HandleDisputeCreatedAsync(dispute);
}
else if (stripeEvent.Type == "charge.dispute.closed")
{
var dispute = stripeEvent.Data.Object as Dispute;
if (dispute != null) await HandleDisputeClosedAsync(dispute);
}
return Ok();
}
// ─── Private helpers ─────────────────────────────────────────────────────
/// <summary>
/// Processes a successful <c>payment_intent.succeeded</c> Stripe Connect event for an invoice
/// payment. Strips the surcharge out of the total (surcharge was stored in PI metadata by
/// <see cref="CreateIntent"/>) so only the net amount is applied to <c>Invoice.AmountPaid</c>.
/// Idempotency: skips if the PaymentIntent ID is already recorded with a paid/partial status to
/// prevent double-recording on webhook retries. Uses <c>IgnoreQueryFilters</c> because the
/// invoice belongs to a tenant company and this handler has no HTTP context / tenant claim.
/// Fires email + in-app notifications after persisting, wrapped in separate try/catch blocks so a
/// failed notification never rolls back the payment record.
/// </summary>
private async Task HandlePaymentSucceededAsync(PaymentIntent intent)
{
_logger.LogInformation("HandlePaymentSucceeded: PI={Id} Amount={Amount} Metadata={@Metadata}",
intent.Id, intent.Amount, intent.Metadata);
if (!intent.Metadata.TryGetValue("invoice_id", out var invoiceIdStr)
|| !int.TryParse(invoiceIdStr, out var invoiceId))
{
_logger.LogWarning("PaymentIntent {Id} has no invoice_id metadata", intent.Id);
return;
}
_logger.LogInformation("HandlePaymentSucceeded: looking up invoice {InvoiceId}", invoiceId);
var invoice = await _context.Invoices
.IgnoreQueryFilters()
.Include(i => i.Customer)
.FirstOrDefaultAsync(i => i.Id == invoiceId && !i.IsDeleted);
if (invoice == null)
{
_logger.LogWarning("PaymentIntent {Id} references unknown invoice {InvoiceId}", intent.Id, invoiceId);
return;
}
_logger.LogInformation("HandlePaymentSucceeded: found invoice {InvoiceId} Status={Status} OnlineStatus={OnlineStatus} ExistingPI={ExistingPI}",
invoiceId, invoice.Status, invoice.OnlinePaymentStatus, invoice.StripePaymentIntentId);
// Idempotency guard — skip if this payment was already recorded
if (invoice.StripePaymentIntentId == intent.Id && invoice.OnlinePaymentStatus is OnlinePaymentStatus.Paid or OnlinePaymentStatus.PartiallyPaid)
{
_logger.LogInformation("Duplicate webhook for PaymentIntent {Id} on invoice {InvoiceId} — skipping", intent.Id, invoiceId);
return;
}
var amountPaidDollars = intent.Amount / 100m;
decimal.TryParse(intent.Metadata.GetValueOrDefault("surcharge_amount", "0"), out var surcharge);
var netPayment = amountPaidDollars - surcharge;
invoice.OnlineAmountPaid += netPayment;
invoice.OnlineSurchargeCollected += surcharge;
invoice.AmountPaid += netPayment;
invoice.StripePaymentIntentId = intent.Id;
if (invoice.BalanceDue <= 0)
{
invoice.OnlinePaymentStatus = OnlinePaymentStatus.Paid;
invoice.Status = InvoiceStatus.Paid;
invoice.PaidDate = DateTime.UtcNow;
}
else
{
invoice.OnlinePaymentStatus = OnlinePaymentStatus.PartiallyPaid;
invoice.Status = InvoiceStatus.PartiallyPaid;
}
invoice.UpdatedAt = DateTime.UtcNow;
_context.Update(invoice);
// Create a Payment record so the payment appears in AR and bank reports, and make the
// matching GL entries. Manual payments go through RecordPayment which does the same thing;
// this makes Stripe payments consistent with that path.
var (arAcctId, checkingAcctId) = await GetGlAccountIdsAsync(invoice.CompanyId);
var stripePayment = new Core.Entities.Payment
{
InvoiceId = invoice.Id,
Amount = netPayment,
PaymentDate = DateTime.UtcNow,
PaymentMethod = PowderCoating.Core.Enums.PaymentMethod.CreditDebitCard,
Reference = intent.Id,
Notes = $"Online payment via Stripe. Surcharge: {surcharge:C}",
DepositAccountId = checkingAcctId,
CompanyId = invoice.CompanyId,
CreatedAt = DateTime.UtcNow
};
_context.Payments.Add(stripePayment);
await _context.SaveChangesAsync();
await _accountBalanceService.DebitAsync(checkingAcctId, netPayment);
await _accountBalanceService.CreditAsync(arAcctId, netPayment);
_logger.LogInformation("Online payment of {Amount:C} received for invoice {InvoiceId}", amountPaidDollars, invoiceId);
await _notificationService.NotifyOnlinePaymentReceivedAsync(invoice, netPayment, surcharge, intent.Id);
try
{
var customerName = invoice.Customer?.CompanyName
?? $"{invoice.Customer?.ContactFirstName} {invoice.Customer?.ContactLastName}".Trim();
await _inApp.CreateAsync(
companyId: invoice.CompanyId,
title: "Online Payment Received",
message: $"{customerName} paid {netPayment:C} online for invoice {invoice.InvoiceNumber}.",
notificationType: "InvoicePaid",
link: $"/Invoices/Details/{invoice.Id}",
invoiceId: invoice.Id,
customerId: invoice.CustomerId);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "In-app notification failed for online payment on invoice {InvoiceId}", invoiceId);
}
}
/// <summary>
/// Processes a successful <c>payment_intent.succeeded</c> event for a quote deposit. Creates a
/// <c>Deposit</c> ledger record so the deposit appears in the customer's deposit history and can
/// be auto-applied when an invoice is later created from the job. Deposit records are only created
/// for quotes with a <c>CustomerId</c> (i.e. existing customers); prospect quotes skip the ledger
/// entry because there is no Customer row to link to. The <c>DepositPaymentIntentId</c> check
/// provides idempotency against webhook retries.
/// </summary>
private async Task HandleDepositPaymentSucceededAsync(PaymentIntent intent)
{
if (!intent.Metadata.TryGetValue("quote_id", out var quoteIdStr)
|| !int.TryParse(quoteIdStr, out var quoteId))
{
_logger.LogWarning("Deposit PaymentIntent {Id} has no quote_id metadata", intent.Id);
return;
}
var quote = await _context.Quotes
.IgnoreQueryFilters()
.Include(q => q.Customer)
.FirstOrDefaultAsync(q => q.Id == quoteId && !q.IsDeleted);
if (quote == null)
{
_logger.LogWarning("Deposit PaymentIntent {Id} references unknown quote {QuoteId}", intent.Id, quoteId);
return;
}
// Idempotency guard
if (quote.DepositPaymentIntentId == intent.Id)
{
_logger.LogInformation("Duplicate deposit webhook for PaymentIntent {Id} on quote {QuoteId} — skipping", intent.Id, quoteId);
return;
}
var amountPaidDollars = intent.Amount / 100m;
decimal.TryParse(intent.Metadata.GetValueOrDefault("surcharge_amount", "0"), out var surcharge);
var netDeposit = amountPaidDollars - surcharge;
quote.DepositAmountPaid += netDeposit;
quote.DepositPaymentIntentId = intent.Id;
quote.UpdatedAt = DateTime.UtcNow;
// Create a Deposit record so it shows up in the deposits ledger (customer quotes only)
if (quote.CustomerId.HasValue)
{
var deposit = new Core.Entities.Deposit
{
CompanyId = quote.CompanyId,
CustomerId = quote.CustomerId.Value,
QuoteId = quote.Id,
Amount = netDeposit,
PaymentMethod = Core.Enums.PaymentMethod.CreditDebitCard,
ReceivedDate = DateTime.UtcNow,
Reference = intent.Id,
Notes = $"Online deposit via Stripe. Surcharge: {surcharge:C}",
ReceiptNumber = $"DEP-{quote.QuoteNumber}-{DateTime.UtcNow:yyyyMMddHHmm}",
CreatedAt = DateTime.UtcNow
};
_context.Deposits.Add(deposit);
}
else
{
_logger.LogWarning("Deposit PaymentIntent {Id} on prospect quote {QuoteId} — Deposit record skipped (no CustomerId)", intent.Id, quoteId);
}
_context.Update(quote);
await _context.SaveChangesAsync();
_logger.LogInformation("Deposit of {Amount:C} received for quote {QuoteId}", amountPaidDollars, quoteId);
try
{
await _notificationService.NotifyDepositReceivedAsync(quote, netDeposit, surcharge, intent.Id);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to send deposit notification for quote {QuoteId}", quoteId);
}
try
{
var customerName = quote.Customer?.CompanyName
?? $"{quote.Customer?.ContactFirstName} {quote.Customer?.ContactLastName}".Trim();
if (string.IsNullOrWhiteSpace(customerName))
customerName = quote.ProspectCompanyName ?? quote.ProspectContactName ?? "Customer";
await _inApp.CreateAsync(
companyId: quote.CompanyId,
title: "Online Deposit Received",
message: $"{customerName} paid a {netDeposit:C} deposit online for quote {quote.QuoteNumber}.",
notificationType: "InvoicePaid",
link: $"/Quotes/Details/{quote.Id}",
quoteId: quote.Id,
customerId: quote.CustomerId);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "In-app notification failed for online deposit on quote {QuoteId}", quoteId);
}
}
/// <summary>
/// Processes a <c>charge.refunded</c> Stripe event by creating a <c>Refund</c> ledger record and
/// reversing the corresponding amount from <c>Invoice.AmountPaid</c>. Matches the invoice by
/// <c>StripePaymentIntentId</c>, with a metadata fallback for cases where a later payment
/// overwrote the PI on the invoice (e.g. partial pay → refund → partial pay again). Only the
/// latest refund on the charge is processed per call; Stripe sends a separate event per refund
/// so this is safe. The <c>Refund.Reference</c> column holds the Stripe refund ID and is used
/// for idempotency checks.
/// </summary>
private async Task HandleChargeRefundedAsync(Charge charge)
{
if (string.IsNullOrEmpty(charge.PaymentIntentId))
{
_logger.LogWarning("Charge {ChargeId} refunded but has no PaymentIntentId", charge.Id);
return;
}
var invoice = await _context.Invoices
.IgnoreQueryFilters()
.Include(i => i.Customer)
.FirstOrDefaultAsync(i => i.StripePaymentIntentId == charge.PaymentIntentId && !i.IsDeleted);
// Fallback: look up by invoice_id in charge metadata if the PaymentIntent was overwritten by a later payment
if (invoice == null && charge.Metadata.TryGetValue("invoice_id", out var metaInvoiceIdStr)
&& int.TryParse(metaInvoiceIdStr, out var metaInvoiceId))
{
invoice = await _context.Invoices
.IgnoreQueryFilters()
.Include(i => i.Customer)
.FirstOrDefaultAsync(i => i.Id == metaInvoiceId && !i.IsDeleted);
}
if (invoice == null)
{
_logger.LogWarning("charge.refunded for charge {ChargeId} (PI: {PI}) — invoice not found", charge.Id, charge.PaymentIntentId);
return;
}
// Only process the latest refund on this charge
var latestRefund = charge.Refunds?.Data?.OrderByDescending(r => r.Created).FirstOrDefault();
if (latestRefund == null) return;
// Idempotency: skip if this Stripe refund ID was already recorded
var alreadyRecorded = await _context.Refunds
.AnyAsync(r => r.InvoiceId == invoice.Id && r.Reference == latestRefund.Id && !r.IsDeleted);
if (alreadyRecorded) return;
var refundAmountDollars = latestRefund.Amount / 100m;
var (arAcctIdR, checkingAcctIdR) = await GetGlAccountIdsAsync(invoice.CompanyId);
var refund = new Core.Entities.Refund
{
CompanyId = invoice.CompanyId,
InvoiceId = invoice.Id,
Amount = refundAmountDollars,
RefundDate = latestRefund.Created,
RefundMethod = Core.Enums.PaymentMethod.CreditDebitCard,
Reason = latestRefund.Reason ?? "Stripe refund",
Reference = latestRefund.Id,
Notes = $"Automatic refund via Stripe. PaymentIntent: {charge.PaymentIntentId}",
Status = Core.Enums.RefundStatus.Issued,
IssuedDate = DateTime.UtcNow,
DepositAccountId = checkingAcctIdR,
CreatedAt = DateTime.UtcNow
};
_context.Refunds.Add(refund);
invoice.OnlineAmountPaid = Math.Max(0, invoice.OnlineAmountPaid - refundAmountDollars);
invoice.AmountPaid = Math.Max(0, invoice.AmountPaid - refundAmountDollars);
if (invoice.AmountPaid <= 0)
{
invoice.OnlinePaymentStatus = OnlinePaymentStatus.Refunded;
invoice.Status = InvoiceStatus.Sent;
invoice.PaidDate = null;
}
else if (invoice.BalanceDue > 0)
{
invoice.OnlinePaymentStatus = OnlinePaymentStatus.PartiallyPaid;
invoice.Status = InvoiceStatus.PartiallyPaid;
}
invoice.UpdatedAt = DateTime.UtcNow;
_context.Update(invoice);
await _context.SaveChangesAsync();
// GL: DR AR (customer owes again) / CR Checking (cash left the bank)
await _accountBalanceService.DebitAsync(arAcctIdR, refundAmountDollars);
await _accountBalanceService.CreditAsync(checkingAcctIdR, refundAmountDollars);
_logger.LogInformation("Refund of {Amount:C} recorded for invoice {InvoiceId} (Stripe refund {RefundId})",
refundAmountDollars, invoice.Id, latestRefund.Id);
}
/// <summary>
/// Processes a <c>charge.dispute.created</c> Stripe event. At this stage Stripe has not yet
/// adjudicated the dispute, so no funds are reversed — only a chargeback alert notification is
/// sent to the company so they can gather evidence. Reversal (if lost) is handled by
/// <see cref="HandleDisputeClosedAsync"/>. Notification failure is caught and logged so a broken
/// email config never causes Stripe to see a failed webhook and retry.
/// </summary>
private async Task HandleDisputeCreatedAsync(Dispute dispute)
{
var invoice = await FindInvoiceByPaymentIntentAsync(dispute.PaymentIntentId);
if (invoice == null)
{
_logger.LogWarning("charge.dispute.created for dispute {DisputeId} — invoice not found (PI: {PI})",
dispute.Id, dispute.PaymentIntentId);
return;
}
var amount = dispute.Amount / 100m;
_logger.LogWarning("Stripe dispute {DisputeId} opened on invoice {InvoiceId}, amount {Amount:C}, reason: {Reason}",
dispute.Id, invoice.Id, amount, dispute.Reason);
try
{
await _notificationService.NotifyChargebackAlertAsync(invoice, dispute.Id, amount, dispute.Reason ?? "unspecified");
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to send chargeback alert for invoice {InvoiceId}", invoice.Id);
}
}
/// <summary>
/// Processes a <c>charge.dispute.closed</c> Stripe event. Only acts when <c>dispute.Status ==
/// "lost"</c> — won disputes require no accounting adjustment. On a lost dispute, the funds were
/// already returned to the cardholder by Stripe, so a <c>Refund</c> ledger record is created and
/// the invoice <c>AmountPaid</c> is reversed to reflect the true collected amount. The Stripe
/// dispute ID is stored in <c>Refund.Reference</c> for idempotency — won disputes and duplicate
/// webhook retries are both handled by the <c>alreadyRecorded</c> guard.
/// </summary>
private async Task HandleDisputeClosedAsync(Dispute dispute)
{
var invoice = await FindInvoiceByPaymentIntentAsync(dispute.PaymentIntentId);
if (invoice == null)
{
_logger.LogWarning("charge.dispute.closed for dispute {DisputeId} — invoice not found (PI: {PI})",
dispute.Id, dispute.PaymentIntentId);
return;
}
_logger.LogInformation("Stripe dispute {DisputeId} closed with status '{Status}' on invoice {InvoiceId}",
dispute.Id, dispute.Status, invoice.Id);
if (dispute.Status == "lost")
{
// Idempotency: skip if already recorded
var alreadyRecorded = await _context.Refunds
.AnyAsync(r => r.InvoiceId == invoice.Id && r.Reference == dispute.Id && !r.IsDeleted);
if (alreadyRecorded) return;
var amount = dispute.Amount / 100m;
var (arAcctIdD, checkingAcctIdD) = await GetGlAccountIdsAsync(invoice.CompanyId);
var refund = new Core.Entities.Refund
{
CompanyId = invoice.CompanyId,
InvoiceId = invoice.Id,
Amount = amount,
RefundDate = DateTime.UtcNow,
RefundMethod = Core.Enums.PaymentMethod.CreditDebitCard,
Reason = "Chargeback lost — funds returned to customer",
Reference = dispute.Id,
Notes = $"Automatic chargeback loss via Stripe. Dispute ID: {dispute.Id}",
Status = Core.Enums.RefundStatus.Issued,
IssuedDate = DateTime.UtcNow,
DepositAccountId = checkingAcctIdD,
CreatedAt = DateTime.UtcNow
};
_context.Refunds.Add(refund);
invoice.OnlineAmountPaid = Math.Max(0, invoice.OnlineAmountPaid - amount);
invoice.AmountPaid = Math.Max(0, invoice.AmountPaid - amount);
if (invoice.AmountPaid <= 0)
{
invoice.OnlinePaymentStatus = OnlinePaymentStatus.Refunded;
invoice.Status = InvoiceStatus.Sent;
invoice.PaidDate = null;
}
else if (invoice.BalanceDue > 0)
{
invoice.OnlinePaymentStatus = OnlinePaymentStatus.PartiallyPaid;
invoice.Status = InvoiceStatus.PartiallyPaid;
}
invoice.UpdatedAt = DateTime.UtcNow;
_context.Update(invoice);
await _context.SaveChangesAsync();
await _accountBalanceService.DebitAsync(arAcctIdD, amount);
await _accountBalanceService.CreditAsync(checkingAcctIdD, amount);
_logger.LogWarning("Chargeback lost for invoice {InvoiceId}, {Amount:C} reversed", invoice.Id, amount);
}
}
/// <summary>
/// Looks up an invoice by its stored <c>StripePaymentIntentId</c>. Used by dispute handlers
/// where the invoice ID is not in the Stripe metadata. <c>IgnoreQueryFilters</c> is required
/// because there is no authenticated tenant context in webhook handlers.
/// </summary>
/// <summary>
/// Resolves the primary AR and Checking/Cash account IDs for a company, used by webhook handlers
/// to make GL entries without an authenticated tenant context. Returns nulls gracefully so
/// IAccountBalanceService.DebitAsync/CreditAsync silently skips missing accounts.
/// </summary>
private async Task<(int? ArAccountId, int? CheckingAccountId)> GetGlAccountIdsAsync(int companyId)
{
var ar = await _context.Accounts
.Where(a => a.CompanyId == companyId && a.IsActive && !a.IsDeleted
&& a.AccountSubType == AccountSubTypeEnum.AccountsReceivable)
.Select(a => (int?)a.Id)
.FirstOrDefaultAsync();
var checking = await _context.Accounts
.Where(a => a.CompanyId == companyId && a.IsActive && !a.IsDeleted
&& (a.AccountSubType == AccountSubTypeEnum.Checking
|| a.AccountSubType == AccountSubTypeEnum.Cash))
.Select(a => (int?)a.Id)
.FirstOrDefaultAsync();
return (ar, checking);
}
private async Task<Core.Entities.Invoice?> FindInvoiceByPaymentIntentAsync(string? paymentIntentId)
{
if (string.IsNullOrEmpty(paymentIntentId)) return null;
return await _context.Invoices
.IgnoreQueryFilters()
.Include(i => i.Customer)
.FirstOrDefaultAsync(i => i.StripePaymentIntentId == paymentIntentId && !i.IsDeleted);
}
/// <summary>
/// Validates an invoice payment token and returns the associated invoice and company, or an
/// error message. This is the central guard for all invoice payment actions — token expiry,
/// zero balance, voided status, and inactive Stripe Connect are all checked here. The method uses
/// <c>IgnoreQueryFilters</c> because the token is accessed by unauthenticated customers and the
/// normal multi-tenancy filter would block the query. Callers short-circuit on non-null error.
/// </summary>
private async Task<(Core.Entities.Invoice? Invoice, Core.Entities.Company? Company, string? Error)>
ResolveInvoiceFromTokenAsync(string token)
{
var invoice = await _context.Invoices
.IgnoreQueryFilters()
.FirstOrDefaultAsync(i => i.PaymentLinkToken == token && !i.IsDeleted);
if (invoice == null)
return (null, null, "This payment link is not valid.");
if (invoice.PaymentLinkExpiresAt < DateTime.UtcNow)
return (null, null, "This payment link has expired. Please contact the shop for a new link.");
if (invoice.BalanceDue <= 0)
return (null, null, "This invoice has already been paid in full.");
if (invoice.Status == InvoiceStatus.Voided)
return (null, null, "This invoice has been voided and is no longer payable.");
var company = await _context.Companies.AsNoTracking()
.FirstOrDefaultAsync(c => c.Id == invoice.CompanyId && !c.IsDeleted);
if (company == null || company.StripeConnectStatus != Core.Enums.StripeConnectStatus.Active)
return (null, null, "Online payments are not available for this invoice.");
return (invoice, company, null);
}
/// <summary>
/// Validates a deposit payment token and returns the associated quote and company, or an error
/// message. Mirrors <see cref="ResolveInvoiceFromTokenAsync"/> for the deposit flow. Checks that
/// the quote deposit has not already been fully paid (compares <c>DepositAmountPaid</c> against
/// the computed deposit amount) so the link cannot be reused after a successful payment.
/// </summary>
private async Task<(Core.Entities.Quote? Quote, Core.Entities.Company? Company, string? Error)>
ResolveDepositQuoteFromTokenAsync(string token)
{
var quote = await _context.Quotes
.IgnoreQueryFilters()
.Include(q => q.Customer)
.FirstOrDefaultAsync(q => q.DepositPaymentLinkToken == token && !q.IsDeleted);
if (quote == null)
return (null, null, "This deposit payment link is not valid.");
if (quote.DepositPaymentLinkExpiresAt < DateTime.UtcNow)
return (null, null, "This deposit payment link has expired. Please contact the shop for a new link.");
var depositRequired = Math.Round(quote.Total * (quote.DepositPercent / 100m), 2);
if (quote.DepositAmountPaid >= depositRequired)
return (null, null, "This deposit has already been paid.");
var company = await _context.Companies.AsNoTracking()
.FirstOrDefaultAsync(c => c.Id == quote.CompanyId && !c.IsDeleted);
if (company == null || company.StripeConnectStatus != Core.Enums.StripeConnectStatus.Active)
return (null, null, "Online payments are not available for this deposit.");
return (quote, company, null);
}
/// <summary>
/// Calculates the online payment surcharge to add to the customer-facing total. Companies can
/// configure either a percentage surcharge (e.g. 3% credit card fee pass-through) or a flat
/// surcharge (e.g. $2.00 handling fee) via <c>OnlinePaymentSurchargeType</c> and
/// <c>OnlinePaymentSurchargeValue</c>. Returns 0 for any other surcharge type (including
/// <c>None</c>). The surcharge amount is passed in Stripe metadata so the webhook can strip it
/// back out when crediting the net amount to the invoice.
/// </summary>
private static decimal CalculateSurcharge(decimal amount, Core.Entities.Company company)
{
return company.OnlinePaymentSurchargeType switch
{
OnlinePaymentSurchargeType.Percent =>
Math.Round(amount * (company.OnlinePaymentSurchargeValue / 100m), 2),
OnlinePaymentSurchargeType.Flat =>
company.OnlinePaymentSurchargeValue,
_ => 0m
};
}
}
// ─── View models & request DTOs ──────────────────────────────────────────────
public class PaymentPageViewModel
{
public string InvoiceNumber { get; set; } = string.Empty;
public int InvoiceId { get; set; }
public string Token { get; set; } = string.Empty;
public string CustomerName { get; set; } = string.Empty;
public string CompanyName { get; set; } = string.Empty;
public decimal BalanceDue { get; set; }
public decimal InvoiceTotal { get; set; }
public decimal AmountPaid { get; set; }
public OnlinePaymentSurchargeType SurchargeType { get; set; }
public decimal SurchargeValue { get; set; }
public decimal SurchargeAmount { get; set; }
public decimal TotalWithSurcharge { get; set; }
public DateTime ExpiresAt { get; set; }
public bool IsFullyPaid { get; set; }
public string StripePublishableKey { get; set; } = string.Empty;
public string StripeAccountId { get; set; } = string.Empty;
}
public class DepositPaymentPageViewModel
{
public string QuoteNumber { get; set; } = string.Empty;
public int QuoteId { get; set; }
public string Token { get; set; } = string.Empty;
public string CustomerName { get; set; } = string.Empty;
public string CompanyName { get; set; } = string.Empty;
public decimal DepositAmount { get; set; }
public decimal QuoteTotal { get; set; }
public decimal DepositPercent { get; set; }
public OnlinePaymentSurchargeType SurchargeType { get; set; }
public decimal SurchargeValue { get; set; }
public decimal SurchargeAmount { get; set; }
public decimal TotalWithSurcharge { get; set; }
public DateTime ExpiresAt { get; set; }
public string StripePublishableKey { get; set; } = string.Empty;
public string StripeAccountId { get; set; } = string.Empty;
}
public class InvoiceViewViewModel
{
public string InvoiceNumber { get; set; } = string.Empty;
public DateTime InvoiceDate { get; set; }
public DateTime? DueDate { get; set; }
public string CustomerName { get; set; } = string.Empty;
public string CompanyName { get; set; } = string.Empty;
public string? CompanyPhone { get; set; }
public string? CompanyAddress { get; set; }
public string? LogoFilePath { get; set; }
public decimal SubTotal { get; set; }
public decimal TaxPercent { get; set; }
public decimal TaxAmount { get; set; }
public decimal DiscountAmount { get; set; }
public decimal Total { get; set; }
public decimal AmountPaid { get; set; }
public decimal BalanceDue { get; set; }
public InvoiceStatus Status { get; set; }
public string? Notes { get; set; }
public string? Terms { get; set; }
public string? JobNumber { get; set; }
public string? PaymentUrl { get; set; }
public List<InvoiceViewLineItem> LineItems { get; set; } = new();
}
public class InvoiceViewLineItem
{
public string Description { get; set; } = string.Empty;
public decimal Quantity { get; set; }
public decimal UnitPrice { get; set; }
public decimal TotalPrice { get; set; }
}
public class CreateIntentRequest
{
public decimal Amount { get; set; }
}