1
0
mirror of https://github.com/bitwarden/server synced 2026-01-04 09:33:40 +00:00

[PM-21881] Manage payment details outside of checkout (#6032)

* Add feature flag

* Further establish billing command pattern and use in PreviewTaxAmountCommand

* Add billing address models/commands/queries/tests

* Update TypeReadingJsonConverter to account for new union types

* Add payment method models/commands/queries/tests

* Add credit models/commands/queries/tests

* Add command/query registrations

* Add new endpoints to support new command model and payment functionality

* Run dotnet format

* Add InjectUserAttribute for easier AccountBillilngVNextController handling

* Add InjectOrganizationAttribute for easier OrganizationBillingVNextController handling

* Add InjectProviderAttribute for easier ProviderBillingVNextController handling

* Add XML documentation for billing command pipeline

* Fix StripeConstants post-nullability

* More nullability cleanup

* Run dotnet format
This commit is contained in:
Alex Morask
2025-07-10 08:32:25 -05:00
committed by GitHub
parent 3bfc24523e
commit 7f65a655d4
52 changed files with 3736 additions and 215 deletions

View File

@@ -0,0 +1,62 @@
using Bit.Core.Billing.Constants;
using Microsoft.Extensions.Logging;
using Stripe;
namespace Bit.Core.Billing.Commands;
using static StripeConstants;
public abstract class BillingCommand<T>(
ILogger<T> logger)
{
protected string CommandName => GetType().Name;
/// <summary>
/// Executes the provided function within a predefined execution context, handling any exceptions that occur during the process.
/// </summary>
/// <typeparam name="TSuccess">The type of the successful result expected from the provided function.</typeparam>
/// <param name="function">A function that performs an operation and returns a <see cref="BillingCommandResult{TSuccess}"/>.</param>
/// <returns>A task that represents the operation. The result provides a <see cref="BillingCommandResult{TSuccess}"/> which may indicate success or an error outcome.</returns>
protected async Task<BillingCommandResult<TSuccess>> HandleAsync<TSuccess>(
Func<Task<BillingCommandResult<TSuccess>>> function)
{
try
{
return await function();
}
catch (StripeException stripeException) when (ErrorCodes.Get().Contains(stripeException.StripeError.Code))
{
return stripeException.StripeError.Code switch
{
ErrorCodes.CustomerTaxLocationInvalid =>
new BadRequest("Your location wasn't recognized. Please ensure your country and postal code are valid and try again."),
ErrorCodes.PaymentMethodMicroDepositVerificationAttemptsExceeded =>
new BadRequest("You have exceeded the number of allowed verification attempts. Please contact support for assistance."),
ErrorCodes.PaymentMethodMicroDepositVerificationDescriptorCodeMismatch =>
new BadRequest("The verification code you provided does not match the one sent to your bank account. Please try again."),
ErrorCodes.PaymentMethodMicroDepositVerificationTimeout =>
new BadRequest("Your bank account was not verified within the required time period. Please contact support for assistance."),
ErrorCodes.TaxIdInvalid =>
new BadRequest("The tax ID number you provided was invalid. Please try again or contact support for assistance."),
_ => new Unhandled(stripeException)
};
}
catch (StripeException stripeException)
{
logger.LogError(stripeException,
"{Command}: An error occurred while communicating with Stripe | Code = {Code}", CommandName,
stripeException.StripeError.Code);
return new Unhandled(stripeException);
}
catch (Exception exception)
{
logger.LogError(exception, "{Command}: An unknown error occurred during execution", CommandName);
return new Unhandled(exception);
}
}
}

View File

@@ -0,0 +1,31 @@
#nullable enable
using OneOf;
namespace Bit.Core.Billing.Commands;
public record BadRequest(string Response);
public record Conflict(string Response);
public record Unhandled(Exception? Exception = null, string Response = "Something went wrong with your request. Please contact support for assistance.");
/// <summary>
/// A <see cref="OneOf"/> union type representing the result of a billing command.
/// <remarks>
/// Choices include:
/// <list type="bullet">
/// <item><description><typeparamref name="T"/>: Success</description></item>
/// <item><description><see cref="BadRequest"/>: Invalid input</description></item>
/// <item><description><see cref="Conflict"/>: A known, but unresolvable issue</description></item>
/// <item><description><see cref="Unhandled"/>: An unknown issue</description></item>
/// </list>
/// </remarks>
/// </summary>
/// <typeparam name="T">The successful result type of the operation.</typeparam>
public class BillingCommandResult<T> : OneOfBase<T, BadRequest, Conflict, Unhandled>
{
private BillingCommandResult(OneOf<T, BadRequest, Conflict, Unhandled> input) : base(input) { }
public static implicit operator BillingCommandResult<T>(T output) => new(output);
public static implicit operator BillingCommandResult<T>(BadRequest badRequest) => new(badRequest);
public static implicit operator BillingCommandResult<T>(Conflict conflict) => new(conflict);
public static implicit operator BillingCommandResult<T>(Unhandled unhandled) => new(unhandled);
}

View File

@@ -1,4 +1,6 @@
namespace Bit.Core.Billing.Constants;
using System.Reflection;
namespace Bit.Core.Billing.Constants;
public static class StripeConstants
{
@@ -36,6 +38,13 @@ public static class StripeConstants
public const string PaymentMethodMicroDepositVerificationDescriptorCodeMismatch = "payment_method_microdeposit_verification_descriptor_code_mismatch";
public const string PaymentMethodMicroDepositVerificationTimeout = "payment_method_microdeposit_verification_timeout";
public const string TaxIdInvalid = "tax_id_invalid";
public static string[] Get() =>
typeof(ErrorCodes)
.GetFields(BindingFlags.Public | BindingFlags.Static | BindingFlags.FlattenHierarchy)
.Where(fi => fi is { IsLiteral: true, IsInitOnly: false } && fi.FieldType == typeof(string))
.Select(fi => (string)fi.GetValue(null)!)
.ToArray();
}
public static class InvoiceStatus
@@ -51,6 +60,7 @@ public static class StripeConstants
public const string InvoiceApproved = "invoice_approved";
public const string OrganizationId = "organizationId";
public const string ProviderId = "providerId";
public const string RetiredBraintreeCustomerId = "btCustomerId_old";
public const string UserId = "userId";
}

View File

@@ -1,6 +1,7 @@
using Bit.Core.Billing.Caches;
using Bit.Core.Billing.Caches.Implementations;
using Bit.Core.Billing.Licenses.Extensions;
using Bit.Core.Billing.Payment;
using Bit.Core.Billing.Pricing;
using Bit.Core.Billing.Services;
using Bit.Core.Billing.Services.Implementations;
@@ -27,5 +28,6 @@ public static class ServiceCollectionExtensions
services.AddLicenseServices();
services.AddPricingClient();
services.AddTransient<IPreviewTaxAmountCommand, PreviewTaxAmountCommand>();
services.AddPaymentOperations();
}
}

View File

@@ -1,4 +1,8 @@
using Bit.Core.Entities;
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Entities.Provider;
using Bit.Core.Billing.Enums;
using Bit.Core.Billing.Payment.Models;
using Bit.Core.Entities;
namespace Bit.Core.Billing.Extensions;
@@ -23,4 +27,14 @@ public static class SubscriberExtensions
? subscriberName
: subscriberName[..30];
}
public static ProductUsageType GetProductUsageType(this ISubscriber subscriber)
=> subscriber switch
{
User => ProductUsageType.Personal,
Organization organization when organization.PlanType.GetProductTier() is ProductTierType.Free or ProductTierType.Families => ProductUsageType.Personal,
Organization => ProductUsageType.Business,
Provider => ProductUsageType.Business,
_ => throw new ArgumentOutOfRangeException(nameof(subscriber))
};
}

View File

@@ -1,36 +0,0 @@
using OneOf;
namespace Bit.Core.Billing.Models;
public record BadRequest(string TranslationKey)
{
public static BadRequest TaxIdNumberInvalid => new(BillingErrorTranslationKeys.TaxIdInvalid);
public static BadRequest TaxLocationInvalid => new(BillingErrorTranslationKeys.CustomerTaxLocationInvalid);
public static BadRequest UnknownTaxIdType => new(BillingErrorTranslationKeys.UnknownTaxIdType);
}
public record Unhandled(string TranslationKey = BillingErrorTranslationKeys.UnhandledError);
public class BillingCommandResult<T> : OneOfBase<T, BadRequest, Unhandled>
{
private BillingCommandResult(OneOf<T, BadRequest, Unhandled> input) : base(input) { }
public static implicit operator BillingCommandResult<T>(T output) => new(output);
public static implicit operator BillingCommandResult<T>(BadRequest badRequest) => new(badRequest);
public static implicit operator BillingCommandResult<T>(Unhandled unhandled) => new(unhandled);
}
public static class BillingErrorTranslationKeys
{
// "The tax ID number you provided was invalid. Please try again or contact support."
public const string TaxIdInvalid = "taxIdInvalid";
// "Your location wasn't recognized. Please ensure your country and postal code are valid and try again."
public const string CustomerTaxLocationInvalid = "customerTaxLocationInvalid";
// "Something went wrong with your request. Please contact support."
public const string UnhandledError = "unhandledBillingError";
// "We couldn't find a corresponding tax ID type for the tax ID you provided. Please try again or contact support."
public const string UnknownTaxIdType = "unknownTaxIdType";
}

View File

@@ -0,0 +1,24 @@
using Bit.Core.Settings;
using BitPayLight;
using BitPayLight.Models.Invoice;
namespace Bit.Core.Billing.Payment.Clients;
public interface IBitPayClient
{
Task<Invoice> GetInvoice(string invoiceId);
Task<Invoice> CreateInvoice(Invoice invoice);
}
public class BitPayClient(
GlobalSettings globalSettings) : IBitPayClient
{
private readonly BitPay _bitPay = new(
globalSettings.BitPay.Token, globalSettings.BitPay.Production ? Env.Prod : Env.Test);
public Task<Invoice> GetInvoice(string invoiceId)
=> _bitPay.GetInvoice(invoiceId);
public Task<Invoice> CreateInvoice(Invoice invoice)
=> _bitPay.CreateInvoice(invoice);
}

View File

@@ -0,0 +1,59 @@
#nullable enable
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Entities.Provider;
using Bit.Core.Billing.Commands;
using Bit.Core.Billing.Payment.Clients;
using Bit.Core.Entities;
using Bit.Core.Settings;
using BitPayLight.Models.Invoice;
using Microsoft.Extensions.Logging;
namespace Bit.Core.Billing.Payment.Commands;
public interface ICreateBitPayInvoiceForCreditCommand
{
Task<BillingCommandResult<string>> Run(
ISubscriber subscriber,
decimal amount,
string redirectUrl);
}
public class CreateBitPayInvoiceForCreditCommand(
IBitPayClient bitPayClient,
GlobalSettings globalSettings,
ILogger<CreateBitPayInvoiceForCreditCommand> logger) : BillingCommand<CreateBitPayInvoiceForCreditCommand>(logger), ICreateBitPayInvoiceForCreditCommand
{
public Task<BillingCommandResult<string>> Run(
ISubscriber subscriber,
decimal amount,
string redirectUrl) => HandleAsync<string>(async () =>
{
var (name, email, posData) = GetSubscriberInformation(subscriber);
var invoice = new Invoice
{
Buyer = new Buyer { Email = email, Name = name },
Currency = "USD",
ExtendedNotifications = true,
FullNotifications = true,
ItemDesc = "Bitwarden",
NotificationUrl = globalSettings.BitPay.NotificationUrl,
PosData = posData,
Price = Convert.ToDouble(amount),
RedirectUrl = redirectUrl
};
var created = await bitPayClient.CreateInvoice(invoice);
return created.Url;
});
private static (string? Name, string? Email, string POSData) GetSubscriberInformation(
ISubscriber subscriber) => subscriber switch
{
User user => (user.Email, user.Email, $"userId:{user.Id},accountCredit:1"),
Organization organization => (organization.Name, organization.BillingEmail,
$"organizationId:{organization.Id},accountCredit:1"),
Provider provider => (provider.Name, provider.BillingEmail, $"providerId:{provider.Id},accountCredit:1"),
_ => throw new ArgumentOutOfRangeException(nameof(subscriber))
};
}

View File

@@ -0,0 +1,129 @@
#nullable enable
using Bit.Core.Billing.Commands;
using Bit.Core.Billing.Constants;
using Bit.Core.Billing.Extensions;
using Bit.Core.Billing.Payment.Models;
using Bit.Core.Entities;
using Bit.Core.Services;
using Microsoft.Extensions.Logging;
using Stripe;
namespace Bit.Core.Billing.Payment.Commands;
public interface IUpdateBillingAddressCommand
{
Task<BillingCommandResult<BillingAddress>> Run(
ISubscriber subscriber,
BillingAddress billingAddress);
}
public class UpdateBillingAddressCommand(
ILogger<UpdateBillingAddressCommand> logger,
IStripeAdapter stripeAdapter) : BillingCommand<UpdateBillingAddressCommand>(logger), IUpdateBillingAddressCommand
{
public Task<BillingCommandResult<BillingAddress>> Run(
ISubscriber subscriber,
BillingAddress billingAddress) => HandleAsync(() => subscriber.GetProductUsageType() switch
{
ProductUsageType.Personal => UpdatePersonalBillingAddressAsync(subscriber, billingAddress),
ProductUsageType.Business => UpdateBusinessBillingAddressAsync(subscriber, billingAddress)
});
private async Task<BillingCommandResult<BillingAddress>> UpdatePersonalBillingAddressAsync(
ISubscriber subscriber,
BillingAddress billingAddress)
{
var customer =
await stripeAdapter.CustomerUpdateAsync(subscriber.GatewayCustomerId,
new CustomerUpdateOptions
{
Address = new AddressOptions
{
Country = billingAddress.Country,
PostalCode = billingAddress.PostalCode,
Line1 = billingAddress.Line1,
Line2 = billingAddress.Line2,
City = billingAddress.City,
State = billingAddress.State
},
Expand = ["subscriptions"]
});
await EnableAutomaticTaxAsync(subscriber, customer);
return BillingAddress.From(customer.Address);
}
private async Task<BillingCommandResult<BillingAddress>> UpdateBusinessBillingAddressAsync(
ISubscriber subscriber,
BillingAddress billingAddress)
{
var customer =
await stripeAdapter.CustomerUpdateAsync(subscriber.GatewayCustomerId,
new CustomerUpdateOptions
{
Address = new AddressOptions
{
Country = billingAddress.Country,
PostalCode = billingAddress.PostalCode,
Line1 = billingAddress.Line1,
Line2 = billingAddress.Line2,
City = billingAddress.City,
State = billingAddress.State
},
Expand = ["subscriptions", "tax_ids"],
TaxExempt = billingAddress.Country != "US"
? StripeConstants.TaxExempt.Reverse
: StripeConstants.TaxExempt.None
});
await EnableAutomaticTaxAsync(subscriber, customer);
var deleteExistingTaxIds = customer.TaxIds?.Any() ?? false
? customer.TaxIds.Select(taxId => stripeAdapter.TaxIdDeleteAsync(customer.Id, taxId.Id)).ToList()
: [];
if (billingAddress.TaxId == null)
{
await Task.WhenAll(deleteExistingTaxIds);
return BillingAddress.From(customer.Address);
}
var updatedTaxId = await stripeAdapter.TaxIdCreateAsync(customer.Id,
new TaxIdCreateOptions { Type = billingAddress.TaxId.Code, Value = billingAddress.TaxId.Value });
if (billingAddress.TaxId.Code == StripeConstants.TaxIdType.SpanishNIF)
{
updatedTaxId = await stripeAdapter.TaxIdCreateAsync(customer.Id,
new TaxIdCreateOptions
{
Type = StripeConstants.TaxIdType.EUVAT,
Value = $"ES{billingAddress.TaxId.Value}"
});
}
await Task.WhenAll(deleteExistingTaxIds);
return BillingAddress.From(customer.Address, updatedTaxId);
}
private async Task EnableAutomaticTaxAsync(
ISubscriber subscriber,
Customer customer)
{
if (!string.IsNullOrEmpty(subscriber.GatewaySubscriptionId))
{
var subscription = customer.Subscriptions.FirstOrDefault(subscription =>
subscription.Id == subscriber.GatewaySubscriptionId);
if (subscription is { AutomaticTax.Enabled: false })
{
await stripeAdapter.SubscriptionUpdateAsync(subscriber.GatewaySubscriptionId,
new SubscriptionUpdateOptions
{
AutomaticTax = new SubscriptionAutomaticTaxOptions { Enabled = true }
});
}
}
}
}

View File

@@ -0,0 +1,205 @@
#nullable enable
using Bit.Core.Billing.Caches;
using Bit.Core.Billing.Commands;
using Bit.Core.Billing.Constants;
using Bit.Core.Billing.Payment.Models;
using Bit.Core.Billing.Services;
using Bit.Core.Entities;
using Bit.Core.Services;
using Bit.Core.Settings;
using Bit.Core.Utilities;
using Braintree;
using Microsoft.Extensions.Logging;
using Stripe;
using Customer = Stripe.Customer;
namespace Bit.Core.Billing.Payment.Commands;
public interface IUpdatePaymentMethodCommand
{
Task<BillingCommandResult<MaskedPaymentMethod>> Run(
ISubscriber subscriber,
TokenizedPaymentMethod paymentMethod,
BillingAddress? billingAddress);
}
public class UpdatePaymentMethodCommand(
IBraintreeGateway braintreeGateway,
IGlobalSettings globalSettings,
ILogger<UpdatePaymentMethodCommand> logger,
ISetupIntentCache setupIntentCache,
IStripeAdapter stripeAdapter,
ISubscriberService subscriberService) : BillingCommand<UpdatePaymentMethodCommand>(logger), IUpdatePaymentMethodCommand
{
private readonly ILogger<UpdatePaymentMethodCommand> _logger = logger;
private static readonly Conflict _conflict = new("We had a problem updating your payment method. Please contact support for assistance.");
public Task<BillingCommandResult<MaskedPaymentMethod>> Run(
ISubscriber subscriber,
TokenizedPaymentMethod paymentMethod,
BillingAddress? billingAddress) => HandleAsync(async () =>
{
var customer = await subscriberService.GetCustomer(subscriber);
var result = paymentMethod.Type switch
{
TokenizablePaymentMethodType.BankAccount => await AddBankAccountAsync(subscriber, customer, paymentMethod.Token),
TokenizablePaymentMethodType.Card => await AddCardAsync(customer, paymentMethod.Token),
TokenizablePaymentMethodType.PayPal => await AddPayPalAsync(subscriber, customer, paymentMethod.Token),
_ => new BadRequest($"Payment method type '{paymentMethod.Type}' is not supported.")
};
if (billingAddress != null && customer.Address is not { Country: not null, PostalCode: not null })
{
await stripeAdapter.CustomerUpdateAsync(customer.Id,
new CustomerUpdateOptions
{
Address = new AddressOptions
{
Country = billingAddress.Country,
PostalCode = billingAddress.PostalCode
}
});
}
return result;
});
private async Task<BillingCommandResult<MaskedPaymentMethod>> AddBankAccountAsync(
ISubscriber subscriber,
Customer customer,
string token)
{
var setupIntents = await stripeAdapter.SetupIntentList(new SetupIntentListOptions
{
Expand = ["data.payment_method"],
PaymentMethod = token
});
switch (setupIntents.Count)
{
case 0:
_logger.LogError("{Command}: Could not find setup intent for subscriber's ({SubscriberID}) bank account", CommandName, subscriber.Id);
return _conflict;
case > 1:
_logger.LogError("{Command}: Found more than one set up intent for subscriber's ({SubscriberID}) bank account", CommandName, subscriber.Id);
return _conflict;
}
var setupIntent = setupIntents.First();
await setupIntentCache.Set(subscriber.Id, setupIntent.Id);
await UnlinkBraintreeCustomerAsync(customer);
return MaskedPaymentMethod.From(setupIntent);
}
private async Task<BillingCommandResult<MaskedPaymentMethod>> AddCardAsync(
Customer customer,
string token)
{
var paymentMethod = await stripeAdapter.PaymentMethodAttachAsync(token, new PaymentMethodAttachOptions { Customer = customer.Id });
await stripeAdapter.CustomerUpdateAsync(customer.Id,
new CustomerUpdateOptions
{
InvoiceSettings = new CustomerInvoiceSettingsOptions { DefaultPaymentMethod = token }
});
await UnlinkBraintreeCustomerAsync(customer);
return MaskedPaymentMethod.From(paymentMethod.Card);
}
private async Task<BillingCommandResult<MaskedPaymentMethod>> AddPayPalAsync(
ISubscriber subscriber,
Customer customer,
string token)
{
Braintree.Customer braintreeCustomer;
if (customer.Metadata.TryGetValue(StripeConstants.MetadataKeys.BraintreeCustomerId, out var braintreeCustomerId))
{
braintreeCustomer = await braintreeGateway.Customer.FindAsync(braintreeCustomerId);
await ReplaceBraintreePaymentMethodAsync(braintreeCustomer, token);
}
else
{
braintreeCustomer = await CreateBraintreeCustomerAsync(subscriber, token);
var metadata = new Dictionary<string, string>
{
[StripeConstants.MetadataKeys.BraintreeCustomerId] = braintreeCustomer.Id
};
await stripeAdapter.CustomerUpdateAsync(customer.Id, new CustomerUpdateOptions { Metadata = metadata });
}
var payPalAccount = braintreeCustomer.DefaultPaymentMethod as PayPalAccount;
return MaskedPaymentMethod.From(payPalAccount!);
}
private async Task<Braintree.Customer> CreateBraintreeCustomerAsync(
ISubscriber subscriber,
string token)
{
var braintreeCustomerId =
subscriber.BraintreeCustomerIdPrefix() +
subscriber.Id.ToString("N").ToLower() +
CoreHelpers.RandomString(3, upper: false, numeric: false);
var result = await braintreeGateway.Customer.CreateAsync(new CustomerRequest
{
Id = braintreeCustomerId,
CustomFields = new Dictionary<string, string>
{
[subscriber.BraintreeIdField()] = subscriber.Id.ToString(),
[subscriber.BraintreeCloudRegionField()] = globalSettings.BaseServiceUri.CloudRegion
},
Email = subscriber.BillingEmailAddress(),
PaymentMethodNonce = token
});
return result.Target;
}
private async Task ReplaceBraintreePaymentMethodAsync(
Braintree.Customer customer,
string token)
{
var existing = customer.DefaultPaymentMethod;
var result = await braintreeGateway.PaymentMethod.CreateAsync(new PaymentMethodRequest
{
CustomerId = customer.Id,
PaymentMethodNonce = token
});
await braintreeGateway.Customer.UpdateAsync(
customer.Id,
new CustomerRequest { DefaultPaymentMethodToken = result.Target.Token });
if (existing != null)
{
await braintreeGateway.PaymentMethod.DeleteAsync(existing.Token);
}
}
private async Task UnlinkBraintreeCustomerAsync(
Customer customer)
{
if (customer.Metadata.TryGetValue(StripeConstants.MetadataKeys.BraintreeCustomerId, out var braintreeCustomerId))
{
var metadata = new Dictionary<string, string>
{
[StripeConstants.MetadataKeys.RetiredBraintreeCustomerId] = braintreeCustomerId,
[StripeConstants.MetadataKeys.BraintreeCustomerId] = string.Empty
};
await stripeAdapter.CustomerUpdateAsync(customer.Id, new CustomerUpdateOptions { Metadata = metadata });
}
}
}

View File

@@ -0,0 +1,63 @@
#nullable enable
using Bit.Core.Billing.Caches;
using Bit.Core.Billing.Commands;
using Bit.Core.Billing.Payment.Models;
using Bit.Core.Entities;
using Bit.Core.Services;
using Microsoft.Extensions.Logging;
using Stripe;
namespace Bit.Core.Billing.Payment.Commands;
public interface IVerifyBankAccountCommand
{
Task<BillingCommandResult<MaskedPaymentMethod>> Run(
ISubscriber subscriber,
string descriptorCode);
}
public class VerifyBankAccountCommand(
ILogger<VerifyBankAccountCommand> logger,
ISetupIntentCache setupIntentCache,
IStripeAdapter stripeAdapter) : BillingCommand<VerifyBankAccountCommand>(logger), IVerifyBankAccountCommand
{
private readonly ILogger<VerifyBankAccountCommand> _logger = logger;
private static readonly Conflict _conflict =
new("We had a problem verifying your bank account. Please contact support for assistance.");
public Task<BillingCommandResult<MaskedPaymentMethod>> Run(
ISubscriber subscriber,
string descriptorCode) => HandleAsync<MaskedPaymentMethod>(async () =>
{
var setupIntentId = await setupIntentCache.Get(subscriber.Id);
if (string.IsNullOrEmpty(setupIntentId))
{
_logger.LogError(
"{Command}: Could not find setup intent to verify subscriber's ({SubscriberID}) bank account",
CommandName, subscriber.Id);
return _conflict;
}
await stripeAdapter.SetupIntentVerifyMicroDeposit(setupIntentId,
new SetupIntentVerifyMicrodepositsOptions { DescriptorCode = descriptorCode });
var setupIntent = await stripeAdapter.SetupIntentGet(setupIntentId,
new SetupIntentGetOptions { Expand = ["payment_method"] });
var paymentMethod = await stripeAdapter.PaymentMethodAttachAsync(setupIntent.PaymentMethodId,
new PaymentMethodAttachOptions { Customer = subscriber.GatewayCustomerId });
await stripeAdapter.CustomerUpdateAsync(subscriber.GatewayCustomerId,
new CustomerUpdateOptions
{
InvoiceSettings = new CustomerInvoiceSettingsOptions
{
DefaultPaymentMethod = setupIntent.PaymentMethodId
}
});
return MaskedPaymentMethod.From(paymentMethod.UsBankAccount);
});
}

View File

@@ -0,0 +1,30 @@
#nullable enable
using Stripe;
namespace Bit.Core.Billing.Payment.Models;
public record TaxID(string Code, string Value);
public record BillingAddress
{
public required string Country { get; set; }
public required string PostalCode { get; set; }
public string? Line1 { get; set; }
public string? Line2 { get; set; }
public string? City { get; set; }
public string? State { get; set; }
public TaxID? TaxId { get; set; }
public static BillingAddress From(Address address) => new()
{
Country = address.Country,
PostalCode = address.PostalCode,
Line1 = address.Line1,
Line2 = address.Line2,
City = address.City,
State = address.State
};
public static BillingAddress From(Address address, TaxId? taxId) =>
From(address) with { TaxId = taxId != null ? new TaxID(taxId.Type, taxId.Value) : null };
}

View File

@@ -0,0 +1,120 @@
#nullable enable
using System.Text.Json;
using System.Text.Json.Serialization;
using Bit.Core.Billing.Pricing.JSON;
using Braintree;
using OneOf;
using Stripe;
namespace Bit.Core.Billing.Payment.Models;
public record MaskedBankAccount
{
public required string BankName { get; init; }
public required string Last4 { get; init; }
public required bool Verified { get; init; }
public string Type => "bankAccount";
}
public record MaskedCard
{
public required string Brand { get; init; }
public required string Last4 { get; init; }
public required string Expiration { get; init; }
public string Type => "card";
}
public record MaskedPayPalAccount
{
public required string Email { get; init; }
public string Type => "payPal";
}
[JsonConverter(typeof(MaskedPaymentMethodJsonConverter))]
public class MaskedPaymentMethod(OneOf<MaskedBankAccount, MaskedCard, MaskedPayPalAccount> input)
: OneOfBase<MaskedBankAccount, MaskedCard, MaskedPayPalAccount>(input)
{
public static implicit operator MaskedPaymentMethod(MaskedBankAccount bankAccount) => new(bankAccount);
public static implicit operator MaskedPaymentMethod(MaskedCard card) => new(card);
public static implicit operator MaskedPaymentMethod(MaskedPayPalAccount payPal) => new(payPal);
public static MaskedPaymentMethod From(BankAccount bankAccount) => new MaskedBankAccount
{
BankName = bankAccount.BankName,
Last4 = bankAccount.Last4,
Verified = bankAccount.Status == "verified"
};
public static MaskedPaymentMethod From(Card card) => new MaskedCard
{
Brand = card.Brand.ToLower(),
Last4 = card.Last4,
Expiration = $"{card.ExpMonth:00}/{card.ExpYear}"
};
public static MaskedPaymentMethod From(PaymentMethodCard card) => new MaskedCard
{
Brand = card.Brand.ToLower(),
Last4 = card.Last4,
Expiration = $"{card.ExpMonth:00}/{card.ExpYear}"
};
public static MaskedPaymentMethod From(SetupIntent setupIntent) => new MaskedBankAccount
{
BankName = setupIntent.PaymentMethod.UsBankAccount.BankName,
Last4 = setupIntent.PaymentMethod.UsBankAccount.Last4,
Verified = false
};
public static MaskedPaymentMethod From(SourceCard sourceCard) => new MaskedCard
{
Brand = sourceCard.Brand.ToLower(),
Last4 = sourceCard.Last4,
Expiration = $"{sourceCard.ExpMonth:00}/{sourceCard.ExpYear}"
};
public static MaskedPaymentMethod From(PaymentMethodUsBankAccount bankAccount) => new MaskedBankAccount
{
BankName = bankAccount.BankName,
Last4 = bankAccount.Last4,
Verified = true
};
public static MaskedPaymentMethod From(PayPalAccount payPalAccount) => new MaskedPayPalAccount { Email = payPalAccount.Email };
}
public class MaskedPaymentMethodJsonConverter : TypeReadingJsonConverter<MaskedPaymentMethod>
{
protected override string TypePropertyName => nameof(MaskedBankAccount.Type).ToLower();
public override MaskedPaymentMethod? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
var type = ReadType(reader);
return type switch
{
"bankAccount" => JsonSerializer.Deserialize<MaskedBankAccount>(ref reader, options) switch
{
null => null,
var bankAccount => new MaskedPaymentMethod(bankAccount)
},
"card" => JsonSerializer.Deserialize<MaskedCard>(ref reader, options) switch
{
null => null,
var card => new MaskedPaymentMethod(card)
},
"payPal" => JsonSerializer.Deserialize<MaskedPayPalAccount>(ref reader, options) switch
{
null => null,
var payPal => new MaskedPaymentMethod(payPal)
},
_ => Skip(ref reader)
};
}
public override void Write(Utf8JsonWriter writer, MaskedPaymentMethod value, JsonSerializerOptions options)
=> value.Switch(
bankAccount => JsonSerializer.Serialize(writer, bankAccount, options),
card => JsonSerializer.Serialize(writer, card, options),
payPal => JsonSerializer.Serialize(writer, payPal, options));
}

View File

@@ -0,0 +1,7 @@
namespace Bit.Core.Billing.Payment.Models;
public enum ProductUsageType
{
Personal,
Business
}

View File

@@ -0,0 +1,8 @@
namespace Bit.Core.Billing.Payment.Models;
public enum TokenizablePaymentMethodType
{
BankAccount,
Card,
PayPal
}

View File

@@ -0,0 +1,8 @@
#nullable enable
namespace Bit.Core.Billing.Payment.Models;
public record TokenizedPaymentMethod
{
public required TokenizablePaymentMethodType Type { get; set; }
public required string Token { get; set; }
}

View File

@@ -0,0 +1,41 @@
#nullable enable
using Bit.Core.Billing.Extensions;
using Bit.Core.Billing.Payment.Models;
using Bit.Core.Billing.Services;
using Bit.Core.Entities;
using Stripe;
namespace Bit.Core.Billing.Payment.Queries;
public interface IGetBillingAddressQuery
{
Task<BillingAddress?> Run(ISubscriber subscriber);
}
public class GetBillingAddressQuery(
ISubscriberService subscriberService) : IGetBillingAddressQuery
{
public async Task<BillingAddress?> Run(ISubscriber subscriber)
{
var productUsageType = subscriber.GetProductUsageType();
var options = productUsageType switch
{
ProductUsageType.Business => new CustomerGetOptions { Expand = ["tax_ids"] },
_ => new CustomerGetOptions()
};
var customer = await subscriberService.GetCustomer(subscriber, options);
if (customer is not { Address: { Country: not null, PostalCode: not null } })
{
return null;
}
var taxId = productUsageType == ProductUsageType.Business ? customer.TaxIds?.FirstOrDefault() : null;
return taxId != null
? BillingAddress.From(customer.Address, taxId)
: BillingAddress.From(customer.Address);
}
}

View File

@@ -0,0 +1,26 @@
#nullable enable
using Bit.Core.Billing.Services;
using Bit.Core.Entities;
namespace Bit.Core.Billing.Payment.Queries;
public interface IGetCreditQuery
{
Task<decimal?> Run(ISubscriber subscriber);
}
public class GetCreditQuery(
ISubscriberService subscriberService) : IGetCreditQuery
{
public async Task<decimal?> Run(ISubscriber subscriber)
{
var customer = await subscriberService.GetCustomer(subscriber);
if (customer == null)
{
return null;
}
return Convert.ToDecimal(customer.Balance) * -1 / 100;
}
}

View File

@@ -0,0 +1,96 @@
#nullable enable
using Bit.Core.Billing.Caches;
using Bit.Core.Billing.Constants;
using Bit.Core.Billing.Extensions;
using Bit.Core.Billing.Payment.Models;
using Bit.Core.Billing.Services;
using Bit.Core.Entities;
using Bit.Core.Services;
using Braintree;
using Microsoft.Extensions.Logging;
using Stripe;
namespace Bit.Core.Billing.Payment.Queries;
public interface IGetPaymentMethodQuery
{
Task<MaskedPaymentMethod?> Run(ISubscriber subscriber);
}
public class GetPaymentMethodQuery(
IBraintreeGateway braintreeGateway,
ILogger<GetPaymentMethodQuery> logger,
ISetupIntentCache setupIntentCache,
IStripeAdapter stripeAdapter,
ISubscriberService subscriberService) : IGetPaymentMethodQuery
{
public async Task<MaskedPaymentMethod?> Run(ISubscriber subscriber)
{
var customer = await subscriberService.GetCustomer(subscriber,
new CustomerGetOptions { Expand = ["default_source", "invoice_settings.default_payment_method"] });
if (customer.Metadata.TryGetValue(StripeConstants.MetadataKeys.BraintreeCustomerId, out var braintreeCustomerId))
{
var braintreeCustomer = await braintreeGateway.Customer.FindAsync(braintreeCustomerId);
if (braintreeCustomer.DefaultPaymentMethod is PayPalAccount payPalAccount)
{
return new MaskedPayPalAccount { Email = payPalAccount.Email };
}
logger.LogWarning("Subscriber ({SubscriberID}) has a linked Braintree customer ({BraintreeCustomerId}) with no PayPal account.", subscriber.Id, braintreeCustomerId);
return null;
}
var paymentMethod = customer.InvoiceSettings.DefaultPaymentMethod != null
? customer.InvoiceSettings.DefaultPaymentMethod.Type switch
{
"card" => MaskedPaymentMethod.From(customer.InvoiceSettings.DefaultPaymentMethod.Card),
"us_bank_account" => MaskedPaymentMethod.From(customer.InvoiceSettings.DefaultPaymentMethod.UsBankAccount),
_ => null
}
: null;
if (paymentMethod != null)
{
return paymentMethod;
}
if (customer.DefaultSource != null)
{
paymentMethod = customer.DefaultSource switch
{
Card card => MaskedPaymentMethod.From(card),
BankAccount bankAccount => MaskedPaymentMethod.From(bankAccount),
Source { Card: not null } source => MaskedPaymentMethod.From(source.Card),
_ => null
};
if (paymentMethod != null)
{
return paymentMethod;
}
}
var setupIntentId = await setupIntentCache.Get(subscriber.Id);
if (string.IsNullOrEmpty(setupIntentId))
{
return null;
}
var setupIntent = await stripeAdapter.SetupIntentGet(setupIntentId, new SetupIntentGetOptions
{
Expand = ["payment_method"]
});
// ReSharper disable once ConvertIfStatementToReturnStatement
if (!setupIntent.IsUnverifiedBankAccount())
{
return null;
}
return MaskedPaymentMethod.From(setupIntent);
}
}

View File

@@ -0,0 +1,24 @@
using Bit.Core.Billing.Payment.Clients;
using Bit.Core.Billing.Payment.Commands;
using Bit.Core.Billing.Payment.Queries;
using Microsoft.Extensions.DependencyInjection;
namespace Bit.Core.Billing.Payment;
public static class Registrations
{
public static void AddPaymentOperations(this IServiceCollection services)
{
// Commands
services.AddTransient<IBitPayClient, BitPayClient>();
services.AddTransient<ICreateBitPayInvoiceForCreditCommand, CreateBitPayInvoiceForCreditCommand>();
services.AddTransient<IUpdateBillingAddressCommand, UpdateBillingAddressCommand>();
services.AddTransient<IUpdatePaymentMethodCommand, UpdatePaymentMethodCommand>();
services.AddTransient<IVerifyBankAccountCommand, VerifyBankAccountCommand>();
// Queries
services.AddTransient<IGetBillingAddressQuery, GetBillingAddressQuery>();
services.AddTransient<IGetCreditQuery, GetCreditQuery>();
services.AddTransient<IGetPaymentMethodQuery, GetPaymentMethodQuery>();
}
}

View File

@@ -6,7 +6,7 @@ namespace Bit.Core.Billing.Pricing.JSON;
#nullable enable
public abstract class TypeReadingJsonConverter<T> : JsonConverter<T>
public abstract class TypeReadingJsonConverter<T> : JsonConverter<T> where T : class
{
protected virtual string TypePropertyName => nameof(ScalableDTO.Type).ToLower();
@@ -14,7 +14,9 @@ public abstract class TypeReadingJsonConverter<T> : JsonConverter<T>
{
while (reader.Read())
{
if (reader.TokenType != JsonTokenType.PropertyName || reader.GetString()?.ToLower() != TypePropertyName)
if (reader.CurrentDepth != 1 ||
reader.TokenType != JsonTokenType.PropertyName ||
reader.GetString()?.ToLower() != TypePropertyName)
{
continue;
}
@@ -25,4 +27,10 @@ public abstract class TypeReadingJsonConverter<T> : JsonConverter<T>
return null;
}
protected T? Skip(ref Utf8JsonReader reader)
{
reader.Skip();
return null;
}
}

View File

@@ -1,8 +1,8 @@
#nullable enable
using Bit.Core.Billing.Commands;
using Bit.Core.Billing.Constants;
using Bit.Core.Billing.Enums;
using Bit.Core.Billing.Extensions;
using Bit.Core.Billing.Models;
using Bit.Core.Billing.Pricing;
using Bit.Core.Billing.Tax.Services;
using Bit.Core.Services;
@@ -20,111 +20,95 @@ public class PreviewTaxAmountCommand(
ILogger<PreviewTaxAmountCommand> logger,
IPricingClient pricingClient,
IStripeAdapter stripeAdapter,
ITaxService taxService) : IPreviewTaxAmountCommand
ITaxService taxService) : BillingCommand<PreviewTaxAmountCommand>(logger), IPreviewTaxAmountCommand
{
public async Task<BillingCommandResult<decimal>> Run(OrganizationTrialParameters parameters)
{
var (planType, productType, taxInformation) = parameters;
var plan = await pricingClient.GetPlanOrThrow(planType);
var options = new InvoiceCreatePreviewOptions
public Task<BillingCommandResult<decimal>> Run(OrganizationTrialParameters parameters)
=> HandleAsync<decimal>(async () =>
{
Currency = "usd",
CustomerDetails = new InvoiceCustomerDetailsOptions
var (planType, productType, taxInformation) = parameters;
var plan = await pricingClient.GetPlanOrThrow(planType);
var options = new InvoiceCreatePreviewOptions
{
Address = new AddressOptions
Currency = "usd",
CustomerDetails = new InvoiceCustomerDetailsOptions
{
Country = taxInformation.Country,
PostalCode = taxInformation.PostalCode
}
},
SubscriptionDetails = new InvoiceSubscriptionDetailsOptions
{
Items = [
new InvoiceSubscriptionDetailsItemOptions
Address = new AddressOptions
{
Price = plan.HasNonSeatBasedPasswordManagerPlan() ? plan.PasswordManager.StripePlanId : plan.PasswordManager.StripeSeatPlanId,
Quantity = 1
Country = taxInformation.Country,
PostalCode = taxInformation.PostalCode
}
]
}
};
if (productType == ProductType.SecretsManager)
{
options.SubscriptionDetails.Items.Add(new InvoiceSubscriptionDetailsItemOptions
{
Price = plan.SecretsManager.StripeSeatPlanId,
Quantity = 1
});
options.Coupon = StripeConstants.CouponIDs.SecretsManagerStandalone;
}
if (!string.IsNullOrEmpty(taxInformation.TaxId))
{
var taxIdType = taxService.GetStripeTaxCode(
taxInformation.Country,
taxInformation.TaxId);
if (string.IsNullOrEmpty(taxIdType))
{
return BadRequest.UnknownTaxIdType;
}
options.CustomerDetails.TaxIds = [
new InvoiceCustomerDetailsTaxIdOptions
},
SubscriptionDetails = new InvoiceSubscriptionDetailsOptions
{
Type = taxIdType,
Value = taxInformation.TaxId
Items =
[
new InvoiceSubscriptionDetailsItemOptions
{
Price = plan.HasNonSeatBasedPasswordManagerPlan()
? plan.PasswordManager.StripePlanId
: plan.PasswordManager.StripeSeatPlanId,
Quantity = 1
}
]
}
];
if (taxIdType == StripeConstants.TaxIdType.SpanishNIF)
{
options.CustomerDetails.TaxIds.Add(new InvoiceCustomerDetailsTaxIdOptions
{
Type = StripeConstants.TaxIdType.EUVAT,
Value = $"ES{parameters.TaxInformation.TaxId}"
});
}
}
if (planType.GetProductTier() == ProductTierType.Families)
{
options.AutomaticTax = new InvoiceAutomaticTaxOptions { Enabled = true };
}
else
{
options.AutomaticTax = new InvoiceAutomaticTaxOptions
{
Enabled = options.CustomerDetails.Address.Country == "US" ||
options.CustomerDetails.TaxIds is [_, ..]
};
}
try
{
if (productType == ProductType.SecretsManager)
{
options.SubscriptionDetails.Items.Add(new InvoiceSubscriptionDetailsItemOptions
{
Price = plan.SecretsManager.StripeSeatPlanId,
Quantity = 1
});
options.Coupon = StripeConstants.CouponIDs.SecretsManagerStandalone;
}
if (!string.IsNullOrEmpty(taxInformation.TaxId))
{
var taxIdType = taxService.GetStripeTaxCode(
taxInformation.Country,
taxInformation.TaxId);
if (string.IsNullOrEmpty(taxIdType))
{
return new BadRequest(
"We couldn't find a corresponding tax ID type for the tax ID you provided. Please try again or contact support for assistance.");
}
options.CustomerDetails.TaxIds =
[
new InvoiceCustomerDetailsTaxIdOptions { Type = taxIdType, Value = taxInformation.TaxId }
];
if (taxIdType == StripeConstants.TaxIdType.SpanishNIF)
{
options.CustomerDetails.TaxIds.Add(new InvoiceCustomerDetailsTaxIdOptions
{
Type = StripeConstants.TaxIdType.EUVAT,
Value = $"ES{parameters.TaxInformation.TaxId}"
});
}
}
if (planType.GetProductTier() == ProductTierType.Families)
{
options.AutomaticTax = new InvoiceAutomaticTaxOptions { Enabled = true };
}
else
{
options.AutomaticTax = new InvoiceAutomaticTaxOptions
{
Enabled = options.CustomerDetails.Address.Country == "US" ||
options.CustomerDetails.TaxIds is [_, ..]
};
}
var invoice = await stripeAdapter.InvoiceCreatePreviewAsync(options);
return Convert.ToDecimal(invoice.Tax) / 100;
}
catch (StripeException stripeException) when (stripeException.StripeError.Code ==
StripeConstants.ErrorCodes.CustomerTaxLocationInvalid)
{
return BadRequest.TaxLocationInvalid;
}
catch (StripeException stripeException) when (stripeException.StripeError.Code ==
StripeConstants.ErrorCodes.TaxIdInvalid)
{
return BadRequest.TaxIdNumberInvalid;
}
catch (StripeException stripeException)
{
logger.LogError(stripeException, "Stripe responded with an error during {Operation}. Code: {Code}", nameof(PreviewTaxAmountCommand), stripeException.StripeError.Code);
return new Unhandled();
}
}
});
}
#region Command Parameters

View File

@@ -155,6 +155,7 @@ public static class FeatureFlagKeys
public const string PM20322_AllowTrialLength0 = "pm-20322-allow-trial-length-0";
public const string PM21092_SetNonUSBusinessUseToReverseCharge = "pm-21092-set-non-us-business-use-to-reverse-charge";
public const string PM21383_GetProviderPriceFromStripe = "pm-21383-get-provider-price-from-stripe";
public const string PM21881_ManagePaymentDetailsOutsideCheckout = "pm-21881-manage-payment-details-outside-checkout";
/* Data Insights and Reporting Team */
public const string RiskInsightsCriticalApplication = "pm-14466-risk-insights-critical-application";