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