Initial commit

This commit is contained in:
2026-04-23 21:38:24 -04:00
commit 63e12a9636
1762 changed files with 1672620 additions and 0 deletions
@@ -0,0 +1,843 @@
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;
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;
public PaymentController(
ApplicationDbContext context,
IStripeConnectService stripeConnect,
INotificationService notificationService,
IInAppNotificationService inApp,
IConfiguration configuration,
ILogger<PaymentController> logger)
{
_context = context;
_stripeConnect = stripeConnect;
_notificationService = notificationService;
_inApp = inApp;
_configuration = configuration;
_logger = logger;
}
// ─── 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",
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.
/// <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 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 /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",
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 ────────────────────────────────────
/// <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 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 ────────────────────────────────────
/// <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);
await _context.SaveChangesAsync();
_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 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,
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();
_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 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,
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();
_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>
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 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 CreateIntentRequest
{
public decimal Amount { get; set; }
}