1
0
mirror of https://github.com/bitwarden/server synced 2025-12-24 04:03:25 +00:00
Files
server/src/Billing/Services/Implementations/StripeEventUtilityService.cs
Alex Morask 04efe402be [PM-28128] Create transaction for bank transfer charges (#6691)
* Create transaction for charges that were the result of a bank transfer

* Claude feedback

* Run dotnet format
2025-12-16 10:12:56 -06:00

479 lines
18 KiB
C#

// FIXME: Update this file to be null safe and then delete the line below
#nullable disable
using Bit.Billing.Constants;
using Bit.Core.Billing.Constants;
using Bit.Core.Billing.Models;
using Bit.Core.Enums;
using Bit.Core.Exceptions;
using Bit.Core.Repositories;
using Bit.Core.Services;
using Bit.Core.Settings;
using Braintree;
using Stripe;
using Customer = Stripe.Customer;
using Subscription = Stripe.Subscription;
using Transaction = Bit.Core.Entities.Transaction;
using TransactionType = Bit.Core.Enums.TransactionType;
namespace Bit.Billing.Services.Implementations;
public class StripeEventUtilityService : IStripeEventUtilityService
{
private readonly IStripeFacade _stripeFacade;
private readonly ILogger<StripeEventUtilityService> _logger;
private readonly ITransactionRepository _transactionRepository;
private readonly IMailService _mailService;
private readonly BraintreeGateway _btGateway;
private readonly GlobalSettings _globalSettings;
public StripeEventUtilityService(
IStripeFacade stripeFacade,
ILogger<StripeEventUtilityService> logger,
ITransactionRepository transactionRepository,
IMailService mailService,
GlobalSettings globalSettings)
{
_stripeFacade = stripeFacade;
_logger = logger;
_transactionRepository = transactionRepository;
_mailService = mailService;
_btGateway = new BraintreeGateway
{
Environment = globalSettings.Braintree.Production ?
Braintree.Environment.PRODUCTION : Braintree.Environment.SANDBOX,
MerchantId = globalSettings.Braintree.MerchantId,
PublicKey = globalSettings.Braintree.PublicKey,
PrivateKey = globalSettings.Braintree.PrivateKey
};
_globalSettings = globalSettings;
}
/// <summary>
/// Gets the organizationId, userId, or providerId from the metadata of a Stripe Subscription object.
/// </summary>
/// <param name="metadata"></param>
/// <returns></returns>
public Tuple<Guid?, Guid?, Guid?> GetIdsFromMetadata(Dictionary<string, string> metadata)
{
if (metadata == null || metadata.Count == 0)
{
return new Tuple<Guid?, Guid?, Guid?>(null, null, null);
}
metadata.TryGetValue("organizationId", out var orgIdString);
metadata.TryGetValue("userId", out var userIdString);
metadata.TryGetValue("providerId", out var providerIdString);
orgIdString ??= metadata.FirstOrDefault(x =>
x.Key.Equals("organizationId", StringComparison.OrdinalIgnoreCase)).Value;
userIdString ??= metadata.FirstOrDefault(x =>
x.Key.Equals("userId", StringComparison.OrdinalIgnoreCase)).Value;
providerIdString ??= metadata.FirstOrDefault(x =>
x.Key.Equals("providerId", StringComparison.OrdinalIgnoreCase)).Value;
Guid? organizationId = string.IsNullOrWhiteSpace(orgIdString) ? null : new Guid(orgIdString);
Guid? userId = string.IsNullOrWhiteSpace(userIdString) ? null : new Guid(userIdString);
Guid? providerId = string.IsNullOrWhiteSpace(providerIdString) ? null : new Guid(providerIdString);
return new Tuple<Guid?, Guid?, Guid?>(organizationId, userId, providerId);
}
/// <summary>
/// Gets the organization or user ID from the metadata of a Stripe Charge object.
/// </summary>
/// <param name="charge"></param>
/// <returns></returns>
public async Task<(Guid?, Guid?, Guid?)> GetEntityIdsFromChargeAsync(Charge charge)
{
var subscriptions = await _stripeFacade.ListSubscriptions(new SubscriptionListOptions
{
Customer = charge.CustomerId
});
foreach (var subscription in subscriptions)
{
if (subscription.Status is StripeSubscriptionStatus.Canceled or StripeSubscriptionStatus.IncompleteExpired)
{
continue;
}
var (organizationId, userId, providerId) = GetIdsFromMetadata(subscription.Metadata);
if (organizationId.HasValue || userId.HasValue || providerId.HasValue)
{
return (organizationId, userId, providerId);
}
}
return (null, null, null);
}
public bool IsSponsoredSubscription(Subscription subscription) =>
SponsoredPlans.All
.Any(p => subscription.Items
.Any(i => i.Plan.Id == p.StripePlanId));
/// <summary>
/// Converts a Stripe Charge object to a Bitwarden Transaction object.
/// </summary>
/// <param name="charge"></param>
/// <param name="organizationId"></param>
/// <param name="userId"></param>
/// /// <param name="providerId"></param>
/// <returns></returns>
public async Task<Transaction> FromChargeToTransactionAsync(Charge charge, Guid? organizationId, Guid? userId, Guid? providerId)
{
var transaction = new Transaction
{
Amount = charge.Amount / 100M,
CreationDate = charge.Created,
OrganizationId = organizationId,
UserId = userId,
ProviderId = providerId,
Type = TransactionType.Charge,
Gateway = GatewayType.Stripe,
GatewayId = charge.Id
};
switch (charge.Source)
{
case Card card:
{
transaction.PaymentMethodType = PaymentMethodType.Card;
transaction.Details = $"{card.Brand}, *{card.Last4}";
break;
}
case BankAccount bankAccount:
{
transaction.PaymentMethodType = PaymentMethodType.BankAccount;
transaction.Details = $"{bankAccount.BankName}, *{bankAccount.Last4}";
break;
}
case Source { Card: not null } source:
{
transaction.PaymentMethodType = PaymentMethodType.Card;
transaction.Details = $"{source.Card.Brand}, *{source.Card.Last4}";
break;
}
case Source { AchDebit: not null } source:
{
transaction.PaymentMethodType = PaymentMethodType.BankAccount;
transaction.Details = $"{source.AchDebit.BankName}, *{source.AchDebit.Last4}";
break;
}
case Source source:
{
if (source.AchCreditTransfer == null)
{
break;
}
var achCreditTransfer = source.AchCreditTransfer;
transaction.PaymentMethodType = PaymentMethodType.BankAccount;
transaction.Details = $"ACH => {achCreditTransfer.BankName}, {achCreditTransfer.AccountNumber}";
break;
}
default:
{
if (charge.PaymentMethodDetails == null)
{
break;
}
if (charge.PaymentMethodDetails.Card != null)
{
var card = charge.PaymentMethodDetails.Card;
transaction.PaymentMethodType = PaymentMethodType.Card;
transaction.Details = $"{card.Brand?.ToUpperInvariant()}, *{card.Last4}";
}
else if (charge.PaymentMethodDetails.UsBankAccount != null)
{
var usBankAccount = charge.PaymentMethodDetails.UsBankAccount;
transaction.PaymentMethodType = PaymentMethodType.BankAccount;
transaction.Details = $"{usBankAccount.BankName}, *{usBankAccount.Last4}";
}
else if (charge.PaymentMethodDetails.AchDebit != null)
{
var achDebit = charge.PaymentMethodDetails.AchDebit;
transaction.PaymentMethodType = PaymentMethodType.BankAccount;
transaction.Details = $"{achDebit.BankName}, *{achDebit.Last4}";
}
else if (charge.PaymentMethodDetails.AchCreditTransfer != null)
{
var achCreditTransfer = charge.PaymentMethodDetails.AchCreditTransfer;
transaction.PaymentMethodType = PaymentMethodType.BankAccount;
transaction.Details = $"ACH => {achCreditTransfer.BankName}, {achCreditTransfer.AccountNumber}";
}
else if (charge.PaymentMethodDetails.CustomerBalance != null)
{
var bankTransferType = await GetFundingBankTransferTypeAsync(charge);
if (!string.IsNullOrEmpty(bankTransferType))
{
transaction.PaymentMethodType = PaymentMethodType.BankAccount;
transaction.Details = bankTransferType switch
{
"eu_bank_transfer" => "EU Bank Transfer",
"gb_bank_transfer" => "GB Bank Transfer",
"jp_bank_transfer" => "JP Bank Transfer",
"mx_bank_transfer" => "MX Bank Transfer",
"us_bank_transfer" => "US Bank Transfer",
_ => "Bank Transfer"
};
}
}
break;
}
}
return transaction;
}
public async Task<bool> AttemptToPayInvoiceAsync(Invoice invoice, bool attemptToPayWithStripe = false)
{
var customer = await _stripeFacade.GetCustomer(invoice.CustomerId);
if (customer?.Metadata?.ContainsKey("btCustomerId") ?? false)
{
return await AttemptToPayInvoiceWithBraintreeAsync(invoice, customer);
}
if (attemptToPayWithStripe)
{
return await AttemptToPayInvoiceWithStripeAsync(invoice);
}
return false;
}
public bool ShouldAttemptToPayInvoice(Invoice invoice) =>
invoice is
{
AmountDue: > 0,
Status: not StripeConstants.InvoiceStatus.Paid,
CollectionMethod: "charge_automatically",
BillingReason: "subscription_cycle" or "automatic_pending_invoice_item_invoice",
Parent.SubscriptionDetails: not null
};
private async Task<bool> AttemptToPayInvoiceWithBraintreeAsync(Invoice invoice, Customer customer)
{
_logger.LogDebug("Attempting to pay invoice with Braintree");
if (!customer?.Metadata?.ContainsKey("btCustomerId") ?? true)
{
_logger.LogWarning(
"Attempted to pay invoice with Braintree but btCustomerId wasn't on Stripe customer metadata");
return false;
}
if (invoice.Parent?.SubscriptionDetails == null)
{
_logger.LogWarning("Invoice parent was not a subscription.");
return false;
}
var subscription = await _stripeFacade.GetSubscription(invoice.Parent.SubscriptionDetails.SubscriptionId);
var (organizationId, userId, providerId) = GetIdsFromMetadata(subscription?.Metadata);
if (!organizationId.HasValue && !userId.HasValue && !providerId.HasValue)
{
_logger.LogWarning(
"Attempted to pay invoice with Braintree but Stripe subscription metadata didn't contain either a organizationId or userId or ");
return false;
}
var orgTransaction = organizationId.HasValue;
string btObjIdField;
Guid btObjId;
if (organizationId.HasValue)
{
btObjIdField = "organization_id";
btObjId = organizationId.Value;
}
else if (userId.HasValue)
{
btObjIdField = "user_id";
btObjId = userId.Value;
}
else
{
btObjIdField = "provider_id";
btObjId = providerId.Value;
}
var btInvoiceAmount = Math.Round(invoice.AmountDue / 100M, 2);
// Check if this invoice already has a Braintree transaction ID to prevent duplicate charges
if (invoice.Metadata?.ContainsKey("btTransactionId") ?? false)
{
_logger.LogWarning("Invoice {InvoiceId} already has a Braintree transaction ({TransactionId}). " +
"Do not charge again to prevent duplicate.",
invoice.Id,
invoice.Metadata["btTransactionId"]);
return false;
}
Result<Braintree.Transaction> transactionResult;
try
{
var transactionRequest = new Braintree.TransactionRequest
{
Amount = btInvoiceAmount,
CustomerId = customer.Metadata["btCustomerId"],
Options = new Braintree.TransactionOptionsRequest
{
SubmitForSettlement = true,
PayPal = new Braintree.TransactionOptionsPayPalRequest
{
CustomField =
$"{btObjIdField}:{btObjId},region:{_globalSettings.BaseServiceUri.CloudRegion}"
}
},
CustomFields = new Dictionary<string, string>
{
[btObjIdField] = btObjId.ToString(),
["region"] = _globalSettings.BaseServiceUri.CloudRegion
}
};
_logger.LogInformation("Creating Braintree transaction with Amount: {Amount}, CustomerId: {CustomerId}, " +
"CustomField: {CustomField}, CustomFields: {@CustomFields}",
transactionRequest.Amount,
transactionRequest.CustomerId,
transactionRequest.Options.PayPal.CustomField,
transactionRequest.CustomFields);
transactionResult = await _btGateway.Transaction.SaleAsync(transactionRequest);
}
catch (NotFoundException e)
{
_logger.LogError(e,
"Attempted to make a payment with Braintree, but customer did not exist for the given btCustomerId present on the Stripe metadata");
throw;
}
catch (Exception e)
{
_logger.LogError(e, "Exception occurred while trying to pay invoice with Braintree");
throw;
}
if (!transactionResult.IsSuccess())
{
_logger.LogWarning("Braintree transaction failed. Error: {ErrorMessage}, Transaction Status: {Status}, Validation Errors: {ValidationErrors}",
transactionResult.Message,
transactionResult.Target?.Status,
string.Join(", ", transactionResult.Errors.DeepAll().Select(e => $"Code: {e.Code}, Message: {e.Message}, Attribute: {e.Attribute}")));
if (invoice.AttemptCount < 4)
{
await _mailService.SendPaymentFailedAsync(customer.Email, btInvoiceAmount, true);
}
return false;
}
try
{
await _stripeFacade.UpdateInvoice(invoice.Id, new InvoiceUpdateOptions
{
Metadata = new Dictionary<string, string>
{
["btTransactionId"] = transactionResult.Target.Id,
["btPayPalTransactionId"] =
transactionResult.Target.PayPalDetails?.AuthorizationId
}
});
await _stripeFacade.PayInvoice(invoice.Id, new InvoicePayOptions { PaidOutOfBand = true });
}
catch (Exception e)
{
await _btGateway.Transaction.RefundAsync(transactionResult.Target.Id);
if (e.Message.Contains("Invoice is already paid"))
{
await _stripeFacade.UpdateInvoice(invoice.Id, new InvoiceUpdateOptions
{
Metadata = invoice.Metadata
});
}
else
{
throw;
}
}
return true;
}
private async Task<bool> AttemptToPayInvoiceWithStripeAsync(Invoice invoice)
{
try
{
await _stripeFacade.PayInvoice(invoice.Id);
return true;
}
catch (Exception e)
{
_logger.LogWarning(
e,
"Exception occurred while trying to pay Stripe invoice with Id: {InvoiceId}",
invoice.Id);
throw;
}
}
/// <summary>
/// Retrieves the bank transfer type that funded a charge paid via customer balance.
/// </summary>
/// <param name="charge">The charge to analyze.</param>
/// <returns>
/// The bank transfer type (e.g., "us_bank_transfer", "eu_bank_transfer") if the charge was funded
/// by a bank transfer via customer balance, otherwise null.
/// </returns>
private async Task<string> GetFundingBankTransferTypeAsync(Charge charge)
{
if (charge is not
{
CustomerId: not null,
PaymentIntentId: not null,
PaymentMethodDetails: { Type: "customer_balance" }
})
{
return null;
}
var cashBalanceTransactions = _stripeFacade.GetCustomerCashBalanceTransactions(charge.CustomerId);
string bankTransferType = null;
var matchingPaymentIntentFound = false;
await foreach (var cashBalanceTransaction in cashBalanceTransactions)
{
switch (cashBalanceTransaction)
{
case { Type: "funded", Funded: not null }:
{
bankTransferType = cashBalanceTransaction.Funded.BankTransfer.Type;
break;
}
case { Type: "applied_to_payment", AppliedToPayment: not null }
when cashBalanceTransaction.AppliedToPayment.PaymentIntentId == charge.PaymentIntentId:
{
matchingPaymentIntentFound = true;
break;
}
}
if (matchingPaymentIntentFound && !string.IsNullOrEmpty(bankTransferType))
{
return bankTransferType;
}
}
return null;
}
}