fdac0240d1
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>
257 lines
12 KiB
C#
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);
|
|
}
|
|
}
|
|
|
|
}
|