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:
@ -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))
|
||||
};
|
||||
}
|
129
src/Core/Billing/Payment/Commands/UpdateBillingAddressCommand.cs
Normal file
129
src/Core/Billing/Payment/Commands/UpdateBillingAddressCommand.cs
Normal 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 }
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
205
src/Core/Billing/Payment/Commands/UpdatePaymentMethodCommand.cs
Normal file
205
src/Core/Billing/Payment/Commands/UpdatePaymentMethodCommand.cs
Normal 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 });
|
||||
}
|
||||
}
|
||||
}
|
@ -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);
|
||||
});
|
||||
}
|
Reference in New Issue
Block a user