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; } }