Files
PowderCoatingLogix/src/PowderCoating.Infrastructure/Services/StripeConnectService.cs
T
spouliot fdac0240d1 Fix Stripe receipt_email + online payment surcharge and hardening
Remove receipt_email from PaymentIntent creation so customers can use
any email at Stripe checkout without a stored-email mismatch blocking
payment. Remove now-dead CustomerEmail from PaymentPageViewModel.

Fix surcharge payment input: amount field now represents the total the
customer pays (including fee); JS back-calculates base before sending
to server. Add InvariantCulture to numeric Razor→JS literals to prevent
comma-decimal cultures from truncating surcharge values.

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

257 lines
12 KiB
C#

using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
using PowderCoating.Application.Interfaces;
using PowderCoating.Core.Enums;
using PowderCoating.Core.Interfaces;
using Stripe;
namespace PowderCoating.Infrastructure.Services;
/// <summary>
/// Implements Stripe Connect OAuth onboarding and embedded payment processing for tenants.
/// Each tenant company gets its own Stripe Connect account; payments made by their end customers
/// go directly into that connected account. The platform uses the OAuth flow (not the hosted
/// onboarding link) so tenants can connect an existing Stripe account rather than creating a new one.
/// Connect status on the <c>Company</c> entity uses <see cref="StripeConnectStatus"/>:
/// NotStarted → Pending (OAuth started) → Active (token exchanged) → Deauthorized (tenant revoked).
/// </summary>
public class StripeConnectService : IStripeConnectService
{
private readonly IConfiguration _configuration;
private readonly IUnitOfWork _unitOfWork;
private readonly ILogger<StripeConnectService> _logger;
/// <summary>Secret key for the Connect platform Stripe account (separate from the subscription secret key).</summary>
private string SecretKey => _configuration["Stripe:Connect:SecretKey"]!;
/// <summary>OAuth client ID (ca_...) of the platform's Connect application in the Stripe dashboard.</summary>
private string ConnectClientId => _configuration["Stripe:Connect:ConnectClientId"]!;
/// <summary>
/// Constructs the service. Configuration values are read lazily via properties rather than
/// cached in the constructor so that configuration changes in integration tests take effect
/// without rebuilding the DI container.
/// </summary>
public StripeConnectService(
IConfiguration configuration,
IUnitOfWork unitOfWork,
ILogger<StripeConnectService> logger)
{
_configuration = configuration;
_unitOfWork = unitOfWork;
_logger = logger;
}
/// <summary>
/// Builds the Stripe Connect OAuth authorization URL that the tenant's admin is redirected to.
/// The <paramref name="companyId"/> is passed as the OAuth <c>state</c> parameter so the
/// callback endpoint can identify which company is completing onboarding without relying on
/// session state, which may be lost if the browser opens a new tab for the Stripe flow.
/// <c>scope=read_write</c> is required to create charges on behalf of the connected account.
/// </summary>
public string GetOAuthUrl(int companyId, string redirectUri)
{
var state = companyId.ToString(); // passed back in callback to identify the company
return $"https://connect.stripe.com/oauth/authorize" +
$"?response_type=code" +
$"&client_id={ConnectClientId}" +
$"&scope=read_write" +
$"&redirect_uri={Uri.EscapeDataString(redirectUri)}" +
$"&state={state}";
}
/// <summary>
/// Completes the Stripe Connect OAuth flow by exchanging the one-time authorization
/// <paramref name="code"/> for a permanent Stripe account ID (<c>stripe_user_id</c>).
/// Persists the account ID on the company and sets <see cref="StripeConnectStatus.Active"/>.
/// Returns a structured result tuple rather than throwing so the controller can surface a
/// user-friendly error message without catching exceptions. Stripe errors (invalid code,
/// already used code, etc.) are caught as <see cref="StripeException"/> and returned as
/// a failure with the Stripe error message.
/// </summary>
public async Task<(bool Success, string? ErrorMessage)> HandleOAuthCallbackAsync(string code, int companyId)
{
try
{
var client = new StripeClient(SecretKey);
var oauthService = new OAuthTokenService(client);
var response = await oauthService.CreateAsync(new OAuthTokenCreateOptions
{
GrantType = "authorization_code",
Code = code
});
var company = await _unitOfWork.Companies.GetByIdAsync(companyId, ignoreQueryFilters: true);
if (company == null)
return (false, "Company not found.");
company.StripeAccountId = response.StripeUserId;
company.StripeConnectStatus = StripeConnectStatus.Active;
company.UpdatedAt = DateTime.UtcNow;
await _unitOfWork.Companies.UpdateAsync(company);
await _unitOfWork.CompleteAsync();
_logger.LogInformation("Company {CompanyId} connected Stripe account {AccountId}", companyId, response.StripeUserId);
return (true, null);
}
catch (StripeException ex)
{
_logger.LogError(ex, "Stripe OAuth error for company {CompanyId}", companyId);
return (false, ex.StripeError?.Message ?? ex.Message);
}
}
/// <summary>
/// Deauthorizes the tenant's connected Stripe account and clears the stored account ID.
/// The Stripe deauthorize call revokes the platform's access token, preventing any future
/// charges on behalf of the tenant. If the company has no stored account ID the Stripe call
/// is skipped (e.g. partially-onboarded company) and only the local record is cleared.
/// Sets <see cref="StripeConnectStatus.NotConnected"/> on the company record so the UI
/// correctly shows the "Connect Stripe" button again.
/// </summary>
public async Task<(bool Success, string? ErrorMessage)> DisconnectAsync(int companyId)
{
try
{
var company = await _unitOfWork.Companies.GetByIdAsync(companyId, ignoreQueryFilters: true);
if (company == null)
return (false, "Company not found.");
if (!string.IsNullOrEmpty(company.StripeAccountId))
{
var client = new StripeClient(SecretKey);
var oauthService = new OAuthTokenService(client);
await oauthService.DeauthorizeAsync(new OAuthDeauthorizeOptions
{
ClientId = ConnectClientId,
StripeUserId = company.StripeAccountId
});
}
company.StripeAccountId = null;
company.StripeConnectStatus = StripeConnectStatus.NotConnected;
company.UpdatedAt = DateTime.UtcNow;
await _unitOfWork.Companies.UpdateAsync(company);
await _unitOfWork.CompleteAsync();
_logger.LogInformation("Company {CompanyId} disconnected Stripe account", companyId);
return (true, null);
}
catch (StripeException ex)
{
_logger.LogError(ex, "Stripe disconnect error for company {CompanyId}", companyId);
return (false, ex.StripeError?.Message ?? ex.Message);
}
}
/// <summary>
/// Creates a Stripe PaymentIntent on the tenant's connected account so the customer can pay
/// their invoice online via embedded Stripe Elements in the customer portal. The intent is
/// created with <c>StripeAccount</c> set to <paramref name="connectedAccountId"/> so funds
/// settle directly into the tenant's Stripe account rather than the platform account.
/// The optional <paramref name="surchargeAmount"/> (credit-card processing fee pass-through)
/// is added to the invoice total before converting to cents; the surcharge is stored in
/// intent metadata so the fulfillment logic can record it separately. Returns the
/// <c>client_secret</c> (needed by Stripe.js on the client) and the <c>PaymentIntentId</c>
/// (needed to confirm the payment server-side).
/// </summary>
public async Task<(bool Success, string? ClientSecret, string? PaymentIntentId, string? ErrorMessage)> CreatePaymentIntentAsync(
string connectedAccountId,
decimal invoiceTotal,
decimal surchargeAmount,
string currency,
string invoiceNumber,
int invoiceId)
{
try
{
var totalWithSurcharge = invoiceTotal + surchargeAmount;
var amountInCents = (long)Math.Round(totalWithSurcharge * 100, MidpointRounding.AwayFromZero);
var options = new PaymentIntentCreateOptions
{
Amount = amountInCents,
Currency = currency.ToLower(),
Description = $"Invoice {invoiceNumber}",
Metadata = new Dictionary<string, string>
{
{ "invoice_id", invoiceId.ToString() },
{ "invoice_number", invoiceNumber },
{ "surcharge_amount", surchargeAmount.ToString("F2") }
},
AutomaticPaymentMethods = new PaymentIntentAutomaticPaymentMethodsOptions
{
Enabled = true
}
};
var client = new StripeClient(SecretKey);
var requestOptions = new RequestOptions { StripeAccount = connectedAccountId };
var service = new PaymentIntentService(client);
var intent = await service.CreateAsync(options, requestOptions);
return (true, intent.ClientSecret, intent.Id, null);
}
catch (StripeException ex)
{
_logger.LogError(ex, "Failed to create PaymentIntent for invoice {InvoiceId}", invoiceId);
return (false, null, null, ex.StripeError?.Message ?? ex.Message);
}
}
/// <summary>
/// Creates a Stripe PaymentIntent for a quote deposit on the tenant's connected account.
/// Mirrors <see cref="CreatePaymentIntentAsync"/> but attaches <c>type=deposit</c> and
/// <c>quote_id</c> metadata instead of invoice metadata so the payment confirmation handler
/// can route the received funds to the <c>Deposits</c> table rather than as an invoice
/// payment. Deposits can later be auto-applied to the invoice when it is created.
/// </summary>
public async Task<(bool Success, string? ClientSecret, string? PaymentIntentId, string? ErrorMessage)> CreateDepositPaymentIntentAsync(
string connectedAccountId,
decimal depositAmount,
decimal surchargeAmount,
string currency,
string quoteNumber,
int quoteId)
{
try
{
var totalWithSurcharge = depositAmount + surchargeAmount;
var amountInCents = (long)Math.Round(totalWithSurcharge * 100, MidpointRounding.AwayFromZero);
var options = new PaymentIntentCreateOptions
{
Amount = amountInCents,
Currency = currency.ToLower(),
Description = $"Deposit for quote {quoteNumber}",
Metadata = new Dictionary<string, string>
{
{ "type", "deposit" },
{ "quote_id", quoteId.ToString() },
{ "quote_number", quoteNumber },
{ "surcharge_amount", surchargeAmount.ToString("F2") }
},
AutomaticPaymentMethods = new PaymentIntentAutomaticPaymentMethodsOptions
{
Enabled = true
}
};
var client = new StripeClient(SecretKey);
var requestOptions = new RequestOptions { StripeAccount = connectedAccountId };
var service = new PaymentIntentService(client);
var intent = await service.CreateAsync(options, requestOptions);
return (true, intent.ClientSecret, intent.Id, null);
}
catch (StripeException ex)
{
_logger.LogError(ex, "Failed to create deposit PaymentIntent for quote {QuoteId}", quoteId);
return (false, null, null, ex.StripeError?.Message ?? ex.Message);
}
}
}