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;
///
/// Public (unauthenticated) controller for customer-facing invoice payment pages.
/// All actions here are accessible without login — security is enforced via signed tokens.
///
[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 _logger;
private readonly IAccountBalanceService _accountBalanceService;
public PaymentController(
ApplicationDbContext context,
IStripeConnectService stripeConnect,
INotificationService notificationService,
IInAppNotificationService inApp,
IConfiguration configuration,
ILogger logger,
IAccountBalanceService accountBalanceService)
{
_context = context;
_stripeConnect = stripeConnect;
_notificationService = notificationService;
_inApp = inApp;
_configuration = configuration;
_logger = logger;
_accountBalanceService = accountBalanceService;
}
// ─── GET /pay/{token} ────────────────────────────────────────────────────
///
/// 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 so validation logic is shared with
/// .
///
[HttpGet("/pay/{token}")]
public async Task 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",
CustomerEmail = customer?.Email ?? string.Empty,
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.
///
/// 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.
///
[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.
///
/// 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 netPayment against the invoice balance.
/// Sets OnlinePaymentStatus = Pending and saves the PaymentIntent ID to support idempotency
/// checks in .
///
[HttpPost("/pay/{token}/intent")]
public async Task 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 customer = await _context.Customers.AsNoTracking()
.FirstOrDefaultAsync(c => c.Id == invoice.CustomerId);
var (success, clientSecret, paymentIntentId, stripeError) =
await _stripeConnect.CreatePaymentIntentAsync(
connectedAccountId: company!.StripeAccountId!,
invoiceTotal: request.Amount,
surchargeAmount: surcharge,
currency: "usd",
customerEmail: customer?.Email ?? string.Empty,
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} ────────────────────────────────────────────────
///
/// 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.
///
[HttpGet("/invoice/{token}")]
public async Task 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} ────────────────────────────────────────────
///
/// Renders the customer-facing deposit payment page for an approved quote. The deposit amount is
/// calculated as Quote.Total × (DepositPercent / 100); the percentage is set by the company
/// when generating the quote link. Uses to validate
/// the token and confirm the deposit has not already been paid.
///
[HttpGet("/pay/deposit/{token}")]
public async Task 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",
CustomerEmail = customer?.Email ?? quote.ProspectEmail ?? string.Empty,
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 ────────────────────────────────────
///
/// 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 request.Amount
/// 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 can
/// perform idempotency checks.
///
[HttpPost("/pay/deposit/{token}/intent")]
public async Task 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 customerEmail = quote.Customer?.Email ?? quote.ProspectEmail ?? string.Empty;
var (success, clientSecret, paymentIntentId, stripeError) =
await _stripeConnect.CreateDepositPaymentIntentAsync(
connectedAccountId: company!.StripeAccountId!,
depositAmount: depositAmount,
surchargeAmount: surcharge,
currency: "usd",
customerEmail: customerEmail,
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 ────────────────────────────────────
///
/// Renders the post-deposit success page shown to the customer after Stripe redirects back.
/// Actual deposit recording happens in via webhook,
/// not here — this is purely a user-facing confirmation screen.
///
[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.
///
/// 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
/// StripeWebhookController which covers subscription billing events.
/// Verifies the Stripe-Signature header against the Connect webhook secret before
/// processing. Routes payment_intent.succeeded to either
/// (invoice) or
/// (deposit) based on the type 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.
///
[HttpPost("/stripe/connect-webhook")]
[IgnoreAntiforgeryToken]
public async Task 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 ─────────────────────────────────────────────────────
///
/// Processes a successful payment_intent.succeeded Stripe Connect event for an invoice
/// payment. Strips the surcharge out of the total (surcharge was stored in PI metadata by
/// ) so only the net amount is applied to Invoice.AmountPaid.
/// Idempotency: skips if the PaymentIntent ID is already recorded with a paid/partial status to
/// prevent double-recording on webhook retries. Uses IgnoreQueryFilters 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.
///
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);
}
}
///
/// Processes a successful payment_intent.succeeded event for a quote deposit. Creates a
/// Deposit 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 CustomerId (i.e. existing customers); prospect quotes skip the ledger
/// entry because there is no Customer row to link to. The DepositPaymentIntentId check
/// provides idempotency against webhook retries.
///
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);
}
}
///
/// Processes a charge.refunded Stripe event by creating a Refund ledger record and
/// reversing the corresponding amount from Invoice.AmountPaid. Matches the invoice by
/// StripePaymentIntentId, 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 Refund.Reference column holds the Stripe refund ID and is used
/// for idempotency checks.
///
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);
}
///
/// Processes a charge.dispute.created 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
/// . Notification failure is caught and logged so a broken
/// email config never causes Stripe to see a failed webhook and retry.
///
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);
}
}
///
/// Processes a charge.dispute.closed Stripe event. Only acts when dispute.Status ==
/// "lost" — won disputes require no accounting adjustment. On a lost dispute, the funds were
/// already returned to the cardholder by Stripe, so a Refund ledger record is created and
/// the invoice AmountPaid is reversed to reflect the true collected amount. The Stripe
/// dispute ID is stored in Refund.Reference for idempotency — won disputes and duplicate
/// webhook retries are both handled by the alreadyRecorded guard.
///
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);
}
}
///
/// Looks up an invoice by its stored StripePaymentIntentId. Used by dispute handlers
/// where the invoice ID is not in the Stripe metadata. IgnoreQueryFilters is required
/// because there is no authenticated tenant context in webhook handlers.
///
///
/// 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.
///
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 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);
}
///
/// 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
/// IgnoreQueryFilters 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.
///
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);
}
///
/// Validates a deposit payment token and returns the associated quote and company, or an error
/// message. Mirrors for the deposit flow. Checks that
/// the quote deposit has not already been fully paid (compares DepositAmountPaid against
/// the computed deposit amount) so the link cannot be reused after a successful payment.
///
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);
}
///
/// 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 OnlinePaymentSurchargeType and
/// OnlinePaymentSurchargeValue. Returns 0 for any other surcharge type (including
/// None). The surcharge amount is passed in Stripe metadata so the webhook can strip it
/// back out when crediting the net amount to the invoice.
///
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 CustomerEmail { 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 CustomerEmail { 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 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; }
}