1
0
mirror of https://github.com/bitwarden/server.git synced 2025-07-16 23:27:30 -05: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,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);
});
}