Initial commit
This commit is contained in:
@@ -0,0 +1,260 @@
|
||||
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 customerEmail,
|
||||
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(),
|
||||
ReceiptEmail = customerEmail,
|
||||
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 customerEmail,
|
||||
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(),
|
||||
ReceiptEmail = customerEmail,
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
Reference in New Issue
Block a user