27bfd4db4d
- Stripe payments/refunds/chargebacks now post DR/CR entries (PaymentController) - Vendor credit void now reverses the posted GL lines (VendorCreditsController) - Gift certificate issue/redeem/void post GL to account 2500 GC Liability; FinancialReportService Trial Balance + Balance Sheet include GC liability and breakage income; P&L shows deferred revenue deduction and breakage income line - Customer deposits now post DR Checking / CR 2300 on record, reverse on delete; invoice auto-apply uses DR 2300 / CR AR (not a second bank debit); draft invoice delete reverses deposit-apply GL before the AR reversal - Deposit.DepositAccountId column added; account 2300 seeded via migration - InvoicesController.ApplyCredit now posts DR Sales Discounts / CR AR, consistent with CreditMemosController.Apply - IssueRefund (cash/card) posts DR AR / CR Bank and sets Refund.DepositAccountId; refund modal gains a bank account selector hidden for store-credit path - CancelRefund (cash/card) reverses the IssueRefund GL entries - LedgerService GetAccountLedgerAsync + ComputePriorBalanceAsync now include Refunds, CreditMemoApplications, VendorCreditApplications, GC Liability (2500), and Customer Deposits (2300) so account ledger view and RecalculateAllAsync produce correct balances - Three EF migrations applied: SeedSalesDiscountsAccount, AccountingGapsPhase2, AccountingDepositsGL - Unit tests updated for new IAccountBalanceService constructor params (200/200) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
904 lines
42 KiB
C#
904 lines
42 KiB
C#
using Microsoft.AspNetCore.Authorization;
|
||
using Microsoft.AspNetCore.Mvc;
|
||
using Microsoft.AspNetCore.RateLimiting;
|
||
using Microsoft.EntityFrameworkCore;
|
||
using Microsoft.Extensions.Configuration;
|
||
using PowderCoating.Application.Interfaces;
|
||
using PowderCoating.Core.Enums;
|
||
using PowderCoating.Core.Interfaces;
|
||
using PowderCoating.Infrastructure.Data;
|
||
using PowderCoating.Shared.Constants;
|
||
using Stripe;
|
||
using AccountSubTypeEnum = PowderCoating.Core.Enums.AccountSubType;
|
||
|
||
namespace PowderCoating.Web.Controllers;
|
||
|
||
/// <summary>
|
||
/// Public (unauthenticated) controller for customer-facing invoice payment pages.
|
||
/// All actions here are accessible without login — security is enforced via signed tokens.
|
||
/// </summary>
|
||
[AllowAnonymous]
|
||
[EnableRateLimiting(AppConstants.RateLimitPolicies.Public)]
|
||
public class PaymentController : Controller
|
||
{
|
||
private readonly ApplicationDbContext _context;
|
||
private readonly IStripeConnectService _stripeConnect;
|
||
private readonly INotificationService _notificationService;
|
||
private readonly IInAppNotificationService _inApp;
|
||
private readonly IConfiguration _configuration;
|
||
private readonly ILogger<PaymentController> _logger;
|
||
private readonly IAccountBalanceService _accountBalanceService;
|
||
|
||
public PaymentController(
|
||
ApplicationDbContext context,
|
||
IStripeConnectService stripeConnect,
|
||
INotificationService notificationService,
|
||
IInAppNotificationService inApp,
|
||
IConfiguration configuration,
|
||
ILogger<PaymentController> logger,
|
||
IAccountBalanceService accountBalanceService)
|
||
{
|
||
_context = context;
|
||
_stripeConnect = stripeConnect;
|
||
_notificationService = notificationService;
|
||
_inApp = inApp;
|
||
_configuration = configuration;
|
||
_logger = logger;
|
||
_accountBalanceService = accountBalanceService;
|
||
}
|
||
|
||
// ─── GET /pay/{token} ────────────────────────────────────────────────────
|
||
|
||
/// <summary>
|
||
/// Renders the customer-facing invoice payment page. Resolves the invoice from the GUID token
|
||
/// embedded in the email link, validates that the token is not expired and the invoice is still
|
||
/// unpaid, then builds the view model with surcharge details and the Stripe publishable key.
|
||
/// Uses <see cref="ResolveInvoiceFromTokenAsync"/> so validation logic is shared with
|
||
/// <see cref="CreateIntent"/>.
|
||
/// </summary>
|
||
[HttpGet("/pay/{token}")]
|
||
public async Task<IActionResult> Index(string token)
|
||
{
|
||
var (invoice, company, error) = await ResolveInvoiceFromTokenAsync(token);
|
||
if (error != null) return View("PaymentError", error);
|
||
|
||
var customer = await _context.Customers.AsNoTracking()
|
||
.FirstOrDefaultAsync(c => c.Id == invoice!.CustomerId);
|
||
|
||
var surcharge = CalculateSurcharge(invoice!.BalanceDue, company!);
|
||
|
||
var vm = new PaymentPageViewModel
|
||
{
|
||
InvoiceNumber = invoice!.InvoiceNumber,
|
||
InvoiceId = invoice.Id,
|
||
Token = token,
|
||
CustomerName = customer != null
|
||
? $"{customer.ContactFirstName} {customer.ContactLastName}".Trim()
|
||
: "Valued Customer",
|
||
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);
|
||
|
||
// Create a Payment record so the payment appears in AR and bank reports, and make the
|
||
// matching GL entries. Manual payments go through RecordPayment which does the same thing;
|
||
// this makes Stripe payments consistent with that path.
|
||
var (arAcctId, checkingAcctId) = await GetGlAccountIdsAsync(invoice.CompanyId);
|
||
var stripePayment = new Core.Entities.Payment
|
||
{
|
||
InvoiceId = invoice.Id,
|
||
Amount = netPayment,
|
||
PaymentDate = DateTime.UtcNow,
|
||
PaymentMethod = PowderCoating.Core.Enums.PaymentMethod.CreditDebitCard,
|
||
Reference = intent.Id,
|
||
Notes = $"Online payment via Stripe. Surcharge: {surcharge:C}",
|
||
DepositAccountId = checkingAcctId,
|
||
CompanyId = invoice.CompanyId,
|
||
CreatedAt = DateTime.UtcNow
|
||
};
|
||
_context.Payments.Add(stripePayment);
|
||
|
||
await _context.SaveChangesAsync();
|
||
|
||
await _accountBalanceService.DebitAsync(checkingAcctId, netPayment);
|
||
await _accountBalanceService.CreditAsync(arAcctId, netPayment);
|
||
|
||
_logger.LogInformation("Online payment of {Amount:C} received for invoice {InvoiceId}", amountPaidDollars, invoiceId);
|
||
|
||
await _notificationService.NotifyOnlinePaymentReceivedAsync(invoice, netPayment, surcharge, intent.Id);
|
||
|
||
try
|
||
{
|
||
var customerName = invoice.Customer?.CompanyName
|
||
?? $"{invoice.Customer?.ContactFirstName} {invoice.Customer?.ContactLastName}".Trim();
|
||
await _inApp.CreateAsync(
|
||
companyId: invoice.CompanyId,
|
||
title: "Online Payment Received",
|
||
message: $"{customerName} paid {netPayment:C} online for invoice {invoice.InvoiceNumber}.",
|
||
notificationType: "InvoicePaid",
|
||
link: $"/Invoices/Details/{invoice.Id}",
|
||
invoiceId: invoice.Id,
|
||
customerId: invoice.CustomerId);
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
_logger.LogWarning(ex, "In-app notification failed for online payment on invoice {InvoiceId}", invoiceId);
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Processes a successful <c>payment_intent.succeeded</c> event for a quote deposit. Creates a
|
||
/// <c>Deposit</c> ledger record so the deposit appears in the customer's deposit history and can
|
||
/// be auto-applied when an invoice is later created from the job. Deposit records are only created
|
||
/// for quotes with a <c>CustomerId</c> (i.e. existing customers); prospect quotes skip the ledger
|
||
/// entry because there is no Customer row to link to. The <c>DepositPaymentIntentId</c> check
|
||
/// provides idempotency against webhook retries.
|
||
/// </summary>
|
||
private async Task HandleDepositPaymentSucceededAsync(PaymentIntent intent)
|
||
{
|
||
if (!intent.Metadata.TryGetValue("quote_id", out var quoteIdStr)
|
||
|| !int.TryParse(quoteIdStr, out var quoteId))
|
||
{
|
||
_logger.LogWarning("Deposit PaymentIntent {Id} has no quote_id metadata", intent.Id);
|
||
return;
|
||
}
|
||
|
||
var quote = await _context.Quotes
|
||
.IgnoreQueryFilters()
|
||
.Include(q => q.Customer)
|
||
.FirstOrDefaultAsync(q => q.Id == quoteId && !q.IsDeleted);
|
||
|
||
if (quote == null)
|
||
{
|
||
_logger.LogWarning("Deposit PaymentIntent {Id} references unknown quote {QuoteId}", intent.Id, quoteId);
|
||
return;
|
||
}
|
||
|
||
// Idempotency guard
|
||
if (quote.DepositPaymentIntentId == intent.Id)
|
||
{
|
||
_logger.LogInformation("Duplicate deposit webhook for PaymentIntent {Id} on quote {QuoteId} — skipping", intent.Id, quoteId);
|
||
return;
|
||
}
|
||
|
||
var amountPaidDollars = intent.Amount / 100m;
|
||
decimal.TryParse(intent.Metadata.GetValueOrDefault("surcharge_amount", "0"), out var surcharge);
|
||
var netDeposit = amountPaidDollars - surcharge;
|
||
|
||
quote.DepositAmountPaid += netDeposit;
|
||
quote.DepositPaymentIntentId = intent.Id;
|
||
quote.UpdatedAt = DateTime.UtcNow;
|
||
|
||
// Create a Deposit record so it shows up in the deposits ledger (customer quotes only)
|
||
if (quote.CustomerId.HasValue)
|
||
{
|
||
var deposit = new Core.Entities.Deposit
|
||
{
|
||
CompanyId = quote.CompanyId,
|
||
CustomerId = quote.CustomerId.Value,
|
||
QuoteId = quote.Id,
|
||
Amount = netDeposit,
|
||
PaymentMethod = Core.Enums.PaymentMethod.CreditDebitCard,
|
||
ReceivedDate = DateTime.UtcNow,
|
||
Reference = intent.Id,
|
||
Notes = $"Online deposit via Stripe. Surcharge: {surcharge:C}",
|
||
ReceiptNumber = $"DEP-{quote.QuoteNumber}-{DateTime.UtcNow:yyyyMMddHHmm}",
|
||
CreatedAt = DateTime.UtcNow
|
||
};
|
||
_context.Deposits.Add(deposit);
|
||
}
|
||
else
|
||
{
|
||
_logger.LogWarning("Deposit PaymentIntent {Id} on prospect quote {QuoteId} — Deposit record skipped (no CustomerId)", intent.Id, quoteId);
|
||
}
|
||
|
||
_context.Update(quote);
|
||
await _context.SaveChangesAsync();
|
||
|
||
_logger.LogInformation("Deposit of {Amount:C} received for quote {QuoteId}", amountPaidDollars, quoteId);
|
||
|
||
try
|
||
{
|
||
await _notificationService.NotifyDepositReceivedAsync(quote, netDeposit, surcharge, intent.Id);
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
_logger.LogWarning(ex, "Failed to send deposit notification for quote {QuoteId}", quoteId);
|
||
}
|
||
|
||
try
|
||
{
|
||
var customerName = quote.Customer?.CompanyName
|
||
?? $"{quote.Customer?.ContactFirstName} {quote.Customer?.ContactLastName}".Trim();
|
||
if (string.IsNullOrWhiteSpace(customerName))
|
||
customerName = quote.ProspectCompanyName ?? quote.ProspectContactName ?? "Customer";
|
||
await _inApp.CreateAsync(
|
||
companyId: quote.CompanyId,
|
||
title: "Online Deposit Received",
|
||
message: $"{customerName} paid a {netDeposit:C} deposit online for quote {quote.QuoteNumber}.",
|
||
notificationType: "InvoicePaid",
|
||
link: $"/Quotes/Details/{quote.Id}",
|
||
quoteId: quote.Id,
|
||
customerId: quote.CustomerId);
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
_logger.LogWarning(ex, "In-app notification failed for online deposit on quote {QuoteId}", quoteId);
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Processes a <c>charge.refunded</c> Stripe event by creating a <c>Refund</c> ledger record and
|
||
/// reversing the corresponding amount from <c>Invoice.AmountPaid</c>. Matches the invoice by
|
||
/// <c>StripePaymentIntentId</c>, with a metadata fallback for cases where a later payment
|
||
/// overwrote the PI on the invoice (e.g. partial pay → refund → partial pay again). Only the
|
||
/// latest refund on the charge is processed per call; Stripe sends a separate event per refund
|
||
/// so this is safe. The <c>Refund.Reference</c> column holds the Stripe refund ID and is used
|
||
/// for idempotency checks.
|
||
/// </summary>
|
||
private async Task HandleChargeRefundedAsync(Charge charge)
|
||
{
|
||
if (string.IsNullOrEmpty(charge.PaymentIntentId))
|
||
{
|
||
_logger.LogWarning("Charge {ChargeId} refunded but has no PaymentIntentId", charge.Id);
|
||
return;
|
||
}
|
||
|
||
var invoice = await _context.Invoices
|
||
.IgnoreQueryFilters()
|
||
.Include(i => i.Customer)
|
||
.FirstOrDefaultAsync(i => i.StripePaymentIntentId == charge.PaymentIntentId && !i.IsDeleted);
|
||
|
||
// Fallback: look up by invoice_id in charge metadata if the PaymentIntent was overwritten by a later payment
|
||
if (invoice == null && charge.Metadata.TryGetValue("invoice_id", out var metaInvoiceIdStr)
|
||
&& int.TryParse(metaInvoiceIdStr, out var metaInvoiceId))
|
||
{
|
||
invoice = await _context.Invoices
|
||
.IgnoreQueryFilters()
|
||
.Include(i => i.Customer)
|
||
.FirstOrDefaultAsync(i => i.Id == metaInvoiceId && !i.IsDeleted);
|
||
}
|
||
|
||
if (invoice == null)
|
||
{
|
||
_logger.LogWarning("charge.refunded for charge {ChargeId} (PI: {PI}) — invoice not found", charge.Id, charge.PaymentIntentId);
|
||
return;
|
||
}
|
||
|
||
// Only process the latest refund on this charge
|
||
var latestRefund = charge.Refunds?.Data?.OrderByDescending(r => r.Created).FirstOrDefault();
|
||
if (latestRefund == null) return;
|
||
|
||
// Idempotency: skip if this Stripe refund ID was already recorded
|
||
var alreadyRecorded = await _context.Refunds
|
||
.AnyAsync(r => r.InvoiceId == invoice.Id && r.Reference == latestRefund.Id && !r.IsDeleted);
|
||
if (alreadyRecorded) return;
|
||
|
||
var refundAmountDollars = latestRefund.Amount / 100m;
|
||
|
||
var (arAcctIdR, checkingAcctIdR) = await GetGlAccountIdsAsync(invoice.CompanyId);
|
||
|
||
var refund = new Core.Entities.Refund
|
||
{
|
||
CompanyId = invoice.CompanyId,
|
||
InvoiceId = invoice.Id,
|
||
Amount = refundAmountDollars,
|
||
RefundDate = latestRefund.Created,
|
||
RefundMethod = Core.Enums.PaymentMethod.CreditDebitCard,
|
||
Reason = latestRefund.Reason ?? "Stripe refund",
|
||
Reference = latestRefund.Id,
|
||
Notes = $"Automatic refund via Stripe. PaymentIntent: {charge.PaymentIntentId}",
|
||
Status = Core.Enums.RefundStatus.Issued,
|
||
IssuedDate = DateTime.UtcNow,
|
||
DepositAccountId = checkingAcctIdR,
|
||
CreatedAt = DateTime.UtcNow
|
||
};
|
||
_context.Refunds.Add(refund);
|
||
|
||
invoice.OnlineAmountPaid = Math.Max(0, invoice.OnlineAmountPaid - refundAmountDollars);
|
||
invoice.AmountPaid = Math.Max(0, invoice.AmountPaid - refundAmountDollars);
|
||
|
||
if (invoice.AmountPaid <= 0)
|
||
{
|
||
invoice.OnlinePaymentStatus = OnlinePaymentStatus.Refunded;
|
||
invoice.Status = InvoiceStatus.Sent;
|
||
invoice.PaidDate = null;
|
||
}
|
||
else if (invoice.BalanceDue > 0)
|
||
{
|
||
invoice.OnlinePaymentStatus = OnlinePaymentStatus.PartiallyPaid;
|
||
invoice.Status = InvoiceStatus.PartiallyPaid;
|
||
}
|
||
|
||
invoice.UpdatedAt = DateTime.UtcNow;
|
||
_context.Update(invoice);
|
||
await _context.SaveChangesAsync();
|
||
|
||
// GL: DR AR (customer owes again) / CR Checking (cash left the bank)
|
||
await _accountBalanceService.DebitAsync(arAcctIdR, refundAmountDollars);
|
||
await _accountBalanceService.CreditAsync(checkingAcctIdR, refundAmountDollars);
|
||
|
||
_logger.LogInformation("Refund of {Amount:C} recorded for invoice {InvoiceId} (Stripe refund {RefundId})",
|
||
refundAmountDollars, invoice.Id, latestRefund.Id);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Processes a <c>charge.dispute.created</c> Stripe event. At this stage Stripe has not yet
|
||
/// adjudicated the dispute, so no funds are reversed — only a chargeback alert notification is
|
||
/// sent to the company so they can gather evidence. Reversal (if lost) is handled by
|
||
/// <see cref="HandleDisputeClosedAsync"/>. Notification failure is caught and logged so a broken
|
||
/// email config never causes Stripe to see a failed webhook and retry.
|
||
/// </summary>
|
||
private async Task HandleDisputeCreatedAsync(Dispute dispute)
|
||
{
|
||
var invoice = await FindInvoiceByPaymentIntentAsync(dispute.PaymentIntentId);
|
||
if (invoice == null)
|
||
{
|
||
_logger.LogWarning("charge.dispute.created for dispute {DisputeId} — invoice not found (PI: {PI})",
|
||
dispute.Id, dispute.PaymentIntentId);
|
||
return;
|
||
}
|
||
|
||
var amount = dispute.Amount / 100m;
|
||
_logger.LogWarning("Stripe dispute {DisputeId} opened on invoice {InvoiceId}, amount {Amount:C}, reason: {Reason}",
|
||
dispute.Id, invoice.Id, amount, dispute.Reason);
|
||
|
||
try
|
||
{
|
||
await _notificationService.NotifyChargebackAlertAsync(invoice, dispute.Id, amount, dispute.Reason ?? "unspecified");
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
_logger.LogWarning(ex, "Failed to send chargeback alert for invoice {InvoiceId}", invoice.Id);
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Processes a <c>charge.dispute.closed</c> Stripe event. Only acts when <c>dispute.Status ==
|
||
/// "lost"</c> — won disputes require no accounting adjustment. On a lost dispute, the funds were
|
||
/// already returned to the cardholder by Stripe, so a <c>Refund</c> ledger record is created and
|
||
/// the invoice <c>AmountPaid</c> is reversed to reflect the true collected amount. The Stripe
|
||
/// dispute ID is stored in <c>Refund.Reference</c> for idempotency — won disputes and duplicate
|
||
/// webhook retries are both handled by the <c>alreadyRecorded</c> guard.
|
||
/// </summary>
|
||
private async Task HandleDisputeClosedAsync(Dispute dispute)
|
||
{
|
||
var invoice = await FindInvoiceByPaymentIntentAsync(dispute.PaymentIntentId);
|
||
if (invoice == null)
|
||
{
|
||
_logger.LogWarning("charge.dispute.closed for dispute {DisputeId} — invoice not found (PI: {PI})",
|
||
dispute.Id, dispute.PaymentIntentId);
|
||
return;
|
||
}
|
||
|
||
_logger.LogInformation("Stripe dispute {DisputeId} closed with status '{Status}' on invoice {InvoiceId}",
|
||
dispute.Id, dispute.Status, invoice.Id);
|
||
|
||
if (dispute.Status == "lost")
|
||
{
|
||
// Idempotency: skip if already recorded
|
||
var alreadyRecorded = await _context.Refunds
|
||
.AnyAsync(r => r.InvoiceId == invoice.Id && r.Reference == dispute.Id && !r.IsDeleted);
|
||
if (alreadyRecorded) return;
|
||
|
||
var amount = dispute.Amount / 100m;
|
||
var (arAcctIdD, checkingAcctIdD) = await GetGlAccountIdsAsync(invoice.CompanyId);
|
||
|
||
var refund = new Core.Entities.Refund
|
||
{
|
||
CompanyId = invoice.CompanyId,
|
||
InvoiceId = invoice.Id,
|
||
Amount = amount,
|
||
RefundDate = DateTime.UtcNow,
|
||
RefundMethod = Core.Enums.PaymentMethod.CreditDebitCard,
|
||
Reason = "Chargeback lost — funds returned to customer",
|
||
Reference = dispute.Id,
|
||
Notes = $"Automatic chargeback loss via Stripe. Dispute ID: {dispute.Id}",
|
||
Status = Core.Enums.RefundStatus.Issued,
|
||
IssuedDate = DateTime.UtcNow,
|
||
DepositAccountId = checkingAcctIdD,
|
||
CreatedAt = DateTime.UtcNow
|
||
};
|
||
_context.Refunds.Add(refund);
|
||
|
||
invoice.OnlineAmountPaid = Math.Max(0, invoice.OnlineAmountPaid - amount);
|
||
invoice.AmountPaid = Math.Max(0, invoice.AmountPaid - amount);
|
||
|
||
if (invoice.AmountPaid <= 0)
|
||
{
|
||
invoice.OnlinePaymentStatus = OnlinePaymentStatus.Refunded;
|
||
invoice.Status = InvoiceStatus.Sent;
|
||
invoice.PaidDate = null;
|
||
}
|
||
else if (invoice.BalanceDue > 0)
|
||
{
|
||
invoice.OnlinePaymentStatus = OnlinePaymentStatus.PartiallyPaid;
|
||
invoice.Status = InvoiceStatus.PartiallyPaid;
|
||
}
|
||
|
||
invoice.UpdatedAt = DateTime.UtcNow;
|
||
_context.Update(invoice);
|
||
await _context.SaveChangesAsync();
|
||
|
||
await _accountBalanceService.DebitAsync(arAcctIdD, amount);
|
||
await _accountBalanceService.CreditAsync(checkingAcctIdD, amount);
|
||
|
||
_logger.LogWarning("Chargeback lost for invoice {InvoiceId}, {Amount:C} reversed", invoice.Id, amount);
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Looks up an invoice by its stored <c>StripePaymentIntentId</c>. Used by dispute handlers
|
||
/// where the invoice ID is not in the Stripe metadata. <c>IgnoreQueryFilters</c> is required
|
||
/// because there is no authenticated tenant context in webhook handlers.
|
||
/// </summary>
|
||
/// <summary>
|
||
/// Resolves the primary AR and Checking/Cash account IDs for a company, used by webhook handlers
|
||
/// to make GL entries without an authenticated tenant context. Returns nulls gracefully so
|
||
/// IAccountBalanceService.DebitAsync/CreditAsync silently skips missing accounts.
|
||
/// </summary>
|
||
private async Task<(int? ArAccountId, int? CheckingAccountId)> GetGlAccountIdsAsync(int companyId)
|
||
{
|
||
var ar = await _context.Accounts
|
||
.Where(a => a.CompanyId == companyId && a.IsActive && !a.IsDeleted
|
||
&& a.AccountSubType == AccountSubTypeEnum.AccountsReceivable)
|
||
.Select(a => (int?)a.Id)
|
||
.FirstOrDefaultAsync();
|
||
var checking = await _context.Accounts
|
||
.Where(a => a.CompanyId == companyId && a.IsActive && !a.IsDeleted
|
||
&& (a.AccountSubType == AccountSubTypeEnum.Checking
|
||
|| a.AccountSubType == AccountSubTypeEnum.Cash))
|
||
.Select(a => (int?)a.Id)
|
||
.FirstOrDefaultAsync();
|
||
return (ar, checking);
|
||
}
|
||
|
||
private async Task<Core.Entities.Invoice?> FindInvoiceByPaymentIntentAsync(string? paymentIntentId)
|
||
{
|
||
if (string.IsNullOrEmpty(paymentIntentId)) return null;
|
||
return await _context.Invoices
|
||
.IgnoreQueryFilters()
|
||
.Include(i => i.Customer)
|
||
.FirstOrDefaultAsync(i => i.StripePaymentIntentId == paymentIntentId && !i.IsDeleted);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Validates an invoice payment token and returns the associated invoice and company, or an
|
||
/// error message. This is the central guard for all invoice payment actions — token expiry,
|
||
/// zero balance, voided status, and inactive Stripe Connect are all checked here. The method uses
|
||
/// <c>IgnoreQueryFilters</c> because the token is accessed by unauthenticated customers and the
|
||
/// normal multi-tenancy filter would block the query. Callers short-circuit on non-null error.
|
||
/// </summary>
|
||
private async Task<(Core.Entities.Invoice? Invoice, Core.Entities.Company? Company, string? Error)>
|
||
ResolveInvoiceFromTokenAsync(string token)
|
||
{
|
||
var invoice = await _context.Invoices
|
||
.IgnoreQueryFilters()
|
||
.FirstOrDefaultAsync(i => i.PaymentLinkToken == token && !i.IsDeleted);
|
||
|
||
if (invoice == null)
|
||
return (null, null, "This payment link is not valid.");
|
||
|
||
if (invoice.PaymentLinkExpiresAt < DateTime.UtcNow)
|
||
return (null, null, "This payment link has expired. Please contact the shop for a new link.");
|
||
|
||
if (invoice.BalanceDue <= 0)
|
||
return (null, null, "This invoice has already been paid in full.");
|
||
|
||
if (invoice.Status == InvoiceStatus.Voided)
|
||
return (null, null, "This invoice has been voided and is no longer payable.");
|
||
|
||
var company = await _context.Companies.AsNoTracking()
|
||
.FirstOrDefaultAsync(c => c.Id == invoice.CompanyId && !c.IsDeleted);
|
||
|
||
if (company == null || company.StripeConnectStatus != Core.Enums.StripeConnectStatus.Active)
|
||
return (null, null, "Online payments are not available for this invoice.");
|
||
|
||
return (invoice, company, null);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Validates a deposit payment token and returns the associated quote and company, or an error
|
||
/// message. Mirrors <see cref="ResolveInvoiceFromTokenAsync"/> for the deposit flow. Checks that
|
||
/// the quote deposit has not already been fully paid (compares <c>DepositAmountPaid</c> against
|
||
/// the computed deposit amount) so the link cannot be reused after a successful payment.
|
||
/// </summary>
|
||
private async Task<(Core.Entities.Quote? Quote, Core.Entities.Company? Company, string? Error)>
|
||
ResolveDepositQuoteFromTokenAsync(string token)
|
||
{
|
||
var quote = await _context.Quotes
|
||
.IgnoreQueryFilters()
|
||
.Include(q => q.Customer)
|
||
.FirstOrDefaultAsync(q => q.DepositPaymentLinkToken == token && !q.IsDeleted);
|
||
|
||
if (quote == null)
|
||
return (null, null, "This deposit payment link is not valid.");
|
||
|
||
if (quote.DepositPaymentLinkExpiresAt < DateTime.UtcNow)
|
||
return (null, null, "This deposit payment link has expired. Please contact the shop for a new link.");
|
||
|
||
var depositRequired = Math.Round(quote.Total * (quote.DepositPercent / 100m), 2);
|
||
if (quote.DepositAmountPaid >= depositRequired)
|
||
return (null, null, "This deposit has already been paid.");
|
||
|
||
var company = await _context.Companies.AsNoTracking()
|
||
.FirstOrDefaultAsync(c => c.Id == quote.CompanyId && !c.IsDeleted);
|
||
|
||
if (company == null || company.StripeConnectStatus != Core.Enums.StripeConnectStatus.Active)
|
||
return (null, null, "Online payments are not available for this deposit.");
|
||
|
||
return (quote, company, null);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Calculates the online payment surcharge to add to the customer-facing total. Companies can
|
||
/// configure either a percentage surcharge (e.g. 3% credit card fee pass-through) or a flat
|
||
/// surcharge (e.g. $2.00 handling fee) via <c>OnlinePaymentSurchargeType</c> and
|
||
/// <c>OnlinePaymentSurchargeValue</c>. Returns 0 for any other surcharge type (including
|
||
/// <c>None</c>). The surcharge amount is passed in Stripe metadata so the webhook can strip it
|
||
/// back out when crediting the net amount to the invoice.
|
||
/// </summary>
|
||
private static decimal CalculateSurcharge(decimal amount, Core.Entities.Company company)
|
||
{
|
||
return company.OnlinePaymentSurchargeType switch
|
||
{
|
||
OnlinePaymentSurchargeType.Percent =>
|
||
Math.Round(amount * (company.OnlinePaymentSurchargeValue / 100m), 2),
|
||
OnlinePaymentSurchargeType.Flat =>
|
||
company.OnlinePaymentSurchargeValue,
|
||
_ => 0m
|
||
};
|
||
}
|
||
}
|
||
|
||
// ─── View models & request DTOs ──────────────────────────────────────────────
|
||
|
||
public class PaymentPageViewModel
|
||
{
|
||
public string InvoiceNumber { get; set; } = string.Empty;
|
||
public int InvoiceId { get; set; }
|
||
public string Token { get; set; } = string.Empty;
|
||
public string CustomerName { get; set; } = string.Empty;
|
||
public string 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; }
|
||
}
|