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;
///
/// 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 Company entity uses :
/// NotStarted → Pending (OAuth started) → Active (token exchanged) → Deauthorized (tenant revoked).
///
public class StripeConnectService : IStripeConnectService
{
private readonly IConfiguration _configuration;
private readonly IUnitOfWork _unitOfWork;
private readonly ILogger _logger;
/// Secret key for the Connect platform Stripe account (separate from the subscription secret key).
private string SecretKey => _configuration["Stripe:Connect:SecretKey"]!;
/// OAuth client ID (ca_...) of the platform's Connect application in the Stripe dashboard.
private string ConnectClientId => _configuration["Stripe:Connect:ConnectClientId"]!;
///
/// 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.
///
public StripeConnectService(
IConfiguration configuration,
IUnitOfWork unitOfWork,
ILogger logger)
{
_configuration = configuration;
_unitOfWork = unitOfWork;
_logger = logger;
}
///
/// Builds the Stripe Connect OAuth authorization URL that the tenant's admin is redirected to.
/// The is passed as the OAuth state 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.
/// scope=read_write is required to create charges on behalf of the connected account.
///
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}";
}
///
/// Completes the Stripe Connect OAuth flow by exchanging the one-time authorization
/// for a permanent Stripe account ID (stripe_user_id).
/// Persists the account ID on the company and sets .
/// 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 and returned as
/// a failure with the Stripe error message.
///
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);
}
}
///
/// 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 on the company record so the UI
/// correctly shows the "Connect Stripe" button again.
///
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);
}
}
///
/// 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 StripeAccount set to so funds
/// settle directly into the tenant's Stripe account rather than the platform account.
/// The optional (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
/// client_secret (needed by Stripe.js on the client) and the PaymentIntentId
/// (needed to confirm the payment server-side).
///
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
{
{ "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);
}
}
///
/// Creates a Stripe PaymentIntent for a quote deposit on the tenant's connected account.
/// Mirrors but attaches type=deposit and
/// quote_id metadata instead of invoice metadata so the payment confirmation handler
/// can route the received funds to the Deposits table rather than as an invoice
/// payment. Deposits can later be auto-applied to the invoice when it is created.
///
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
{
{ "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);
}
}
}