mirror of
https://github.com/bitwarden/server.git
synced 2026-01-31 14:13:18 +08:00
391 lines
15 KiB
C#
391 lines
15 KiB
C#
using Bit.Core.AdminConsole.Entities;
|
|
using Bit.Core.Billing.Commands;
|
|
using Bit.Core.Billing.Constants;
|
|
using Bit.Core.Billing.Enums;
|
|
using Bit.Core.Billing.Extensions;
|
|
using Bit.Core.Billing.Organizations.Models;
|
|
using Bit.Core.Billing.Payment.Models;
|
|
using Bit.Core.Billing.Pricing;
|
|
using Bit.Core.Enums;
|
|
using Bit.Core.Services;
|
|
using Bit.Core.Utilities;
|
|
using Microsoft.Extensions.Logging;
|
|
using OneOf;
|
|
using Stripe;
|
|
|
|
namespace Bit.Core.Billing.Organizations.Commands;
|
|
|
|
using static Core.Constants;
|
|
using static StripeConstants;
|
|
|
|
public interface IPreviewOrganizationTaxCommand
|
|
{
|
|
Task<BillingCommandResult<(decimal Tax, decimal Total)>> Run(
|
|
OrganizationSubscriptionPurchase purchase,
|
|
BillingAddress billingAddress);
|
|
|
|
Task<BillingCommandResult<(decimal Tax, decimal Total)>> Run(
|
|
Organization organization,
|
|
OrganizationSubscriptionPlanChange planChange,
|
|
BillingAddress billingAddress);
|
|
|
|
Task<BillingCommandResult<(decimal Tax, decimal Total)>> Run(
|
|
Organization organization,
|
|
OrganizationSubscriptionUpdate update);
|
|
}
|
|
|
|
public class PreviewOrganizationTaxCommand(
|
|
ILogger<PreviewOrganizationTaxCommand> logger,
|
|
IPricingClient pricingClient,
|
|
IStripeAdapter stripeAdapter)
|
|
: BaseBillingCommand<PreviewOrganizationTaxCommand>(logger), IPreviewOrganizationTaxCommand
|
|
{
|
|
public Task<BillingCommandResult<(decimal Tax, decimal Total)>> Run(
|
|
OrganizationSubscriptionPurchase purchase,
|
|
BillingAddress billingAddress)
|
|
=> HandleAsync<(decimal, decimal)>(async () =>
|
|
{
|
|
var plan = await pricingClient.GetPlanOrThrow(purchase.PlanType);
|
|
|
|
var options = GetBaseOptions(billingAddress, purchase.Tier != ProductTierType.Families);
|
|
|
|
var items = new List<InvoiceSubscriptionDetailsItemOptions>();
|
|
|
|
switch (purchase)
|
|
{
|
|
case { PasswordManager.Sponsored: true }:
|
|
var sponsoredPlan = StaticStore.GetSponsoredPlan(PlanSponsorshipType.FamiliesForEnterprise);
|
|
items.Add(new InvoiceSubscriptionDetailsItemOptions
|
|
{
|
|
Price = sponsoredPlan.StripePlanId,
|
|
Quantity = 1
|
|
});
|
|
break;
|
|
|
|
case { SecretsManager.Standalone: true }:
|
|
items.AddRange([
|
|
new InvoiceSubscriptionDetailsItemOptions
|
|
{
|
|
Price = plan.PasswordManager.StripeSeatPlanId,
|
|
Quantity = purchase.PasswordManager.Seats
|
|
},
|
|
new InvoiceSubscriptionDetailsItemOptions
|
|
{
|
|
Price = plan.SecretsManager.StripeSeatPlanId,
|
|
Quantity = purchase.SecretsManager.Seats
|
|
}
|
|
]);
|
|
options.Coupon = CouponIDs.SecretsManagerStandalone;
|
|
break;
|
|
|
|
default:
|
|
items.Add(new InvoiceSubscriptionDetailsItemOptions
|
|
{
|
|
Price = plan.HasNonSeatBasedPasswordManagerPlan()
|
|
? plan.PasswordManager.StripePlanId
|
|
: plan.PasswordManager.StripeSeatPlanId,
|
|
Quantity = purchase.PasswordManager.Seats
|
|
});
|
|
|
|
if (purchase.PasswordManager.AdditionalStorage > 0)
|
|
{
|
|
items.Add(new InvoiceSubscriptionDetailsItemOptions
|
|
{
|
|
Price = plan.PasswordManager.StripeStoragePlanId,
|
|
Quantity = purchase.PasswordManager.AdditionalStorage
|
|
});
|
|
}
|
|
|
|
if (purchase.SecretsManager is { Seats: > 0 })
|
|
{
|
|
items.Add(new InvoiceSubscriptionDetailsItemOptions
|
|
{
|
|
Price = plan.SecretsManager.StripeSeatPlanId,
|
|
Quantity = purchase.SecretsManager.Seats
|
|
});
|
|
|
|
if (purchase.SecretsManager.AdditionalServiceAccounts > 0)
|
|
{
|
|
items.Add(new InvoiceSubscriptionDetailsItemOptions
|
|
{
|
|
Price = plan.SecretsManager.StripeServiceAccountPlanId,
|
|
Quantity = purchase.SecretsManager.AdditionalServiceAccounts
|
|
});
|
|
}
|
|
}
|
|
|
|
break;
|
|
}
|
|
|
|
options.SubscriptionDetails = new InvoiceSubscriptionDetailsOptions { Items = items };
|
|
|
|
var invoice = await stripeAdapter.InvoiceCreatePreviewAsync(options);
|
|
return GetAmounts(invoice);
|
|
});
|
|
|
|
public Task<BillingCommandResult<(decimal Tax, decimal Total)>> Run(
|
|
Organization organization,
|
|
OrganizationSubscriptionPlanChange planChange,
|
|
BillingAddress billingAddress)
|
|
=> HandleAsync<(decimal, decimal)>(async () =>
|
|
{
|
|
if (organization.PlanType.GetProductTier() == ProductTierType.Free)
|
|
{
|
|
var options = GetBaseOptions(billingAddress, planChange.Tier != ProductTierType.Families);
|
|
|
|
var newPlan = await pricingClient.GetPlanOrThrow(planChange.PlanType);
|
|
|
|
var quantity = newPlan.HasNonSeatBasedPasswordManagerPlan() ? 1 : 2;
|
|
|
|
var items = new List<InvoiceSubscriptionDetailsItemOptions>
|
|
{
|
|
new ()
|
|
{
|
|
Price = newPlan.HasNonSeatBasedPasswordManagerPlan()
|
|
? newPlan.PasswordManager.StripePlanId
|
|
: newPlan.PasswordManager.StripeSeatPlanId,
|
|
Quantity = quantity
|
|
}
|
|
};
|
|
|
|
if (organization.UseSecretsManager && planChange.Tier != ProductTierType.Families)
|
|
{
|
|
items.Add(new InvoiceSubscriptionDetailsItemOptions
|
|
{
|
|
Price = newPlan.SecretsManager.StripeSeatPlanId,
|
|
Quantity = 2
|
|
});
|
|
}
|
|
|
|
options.SubscriptionDetails = new InvoiceSubscriptionDetailsOptions { Items = items };
|
|
|
|
var invoice = await stripeAdapter.InvoiceCreatePreviewAsync(options);
|
|
return GetAmounts(invoice);
|
|
}
|
|
else
|
|
{
|
|
if (organization is not
|
|
{
|
|
GatewayCustomerId: not null,
|
|
GatewaySubscriptionId: not null
|
|
})
|
|
{
|
|
return new BadRequest("Organization does not have a subscription.");
|
|
}
|
|
|
|
var options = GetBaseOptions(billingAddress, planChange.Tier != ProductTierType.Families);
|
|
|
|
var subscription = await stripeAdapter.SubscriptionGetAsync(organization.GatewaySubscriptionId,
|
|
new SubscriptionGetOptions { Expand = ["customer"] });
|
|
|
|
if (subscription.Customer.Discount != null)
|
|
{
|
|
options.Coupon = subscription.Customer.Discount.Coupon.Id;
|
|
}
|
|
|
|
var currentPlan = await pricingClient.GetPlanOrThrow(organization.PlanType);
|
|
var newPlan = await pricingClient.GetPlanOrThrow(planChange.PlanType);
|
|
|
|
var subscriptionItemsByPriceId =
|
|
subscription.Items.ToDictionary(subscriptionItem => subscriptionItem.Price.Id);
|
|
|
|
var items = new List<InvoiceSubscriptionDetailsItemOptions>();
|
|
|
|
var passwordManagerSeats = subscriptionItemsByPriceId[
|
|
currentPlan.HasNonSeatBasedPasswordManagerPlan()
|
|
? currentPlan.PasswordManager.StripePlanId
|
|
: currentPlan.PasswordManager.StripeSeatPlanId];
|
|
|
|
var quantity = currentPlan.HasNonSeatBasedPasswordManagerPlan() &&
|
|
!newPlan.HasNonSeatBasedPasswordManagerPlan()
|
|
? (long)organization.Seats!
|
|
: passwordManagerSeats.Quantity;
|
|
|
|
items.Add(new InvoiceSubscriptionDetailsItemOptions
|
|
{
|
|
Price = newPlan.HasNonSeatBasedPasswordManagerPlan()
|
|
? newPlan.PasswordManager.StripePlanId
|
|
: newPlan.PasswordManager.StripeSeatPlanId,
|
|
Quantity = quantity
|
|
});
|
|
|
|
var hasStorage =
|
|
subscriptionItemsByPriceId.TryGetValue(newPlan.PasswordManager.StripeStoragePlanId,
|
|
out var storage);
|
|
|
|
if (hasStorage && storage != null)
|
|
{
|
|
items.Add(new InvoiceSubscriptionDetailsItemOptions
|
|
{
|
|
Price = newPlan.PasswordManager.StripeStoragePlanId,
|
|
Quantity = storage.Quantity
|
|
});
|
|
}
|
|
|
|
var hasSecretsManagerSeats = subscriptionItemsByPriceId.TryGetValue(
|
|
newPlan.SecretsManager.StripeSeatPlanId,
|
|
out var secretsManagerSeats);
|
|
|
|
if (hasSecretsManagerSeats && secretsManagerSeats != null)
|
|
{
|
|
items.Add(new InvoiceSubscriptionDetailsItemOptions
|
|
{
|
|
Price = newPlan.SecretsManager.StripeSeatPlanId,
|
|
Quantity = secretsManagerSeats.Quantity
|
|
});
|
|
|
|
var hasServiceAccounts =
|
|
subscriptionItemsByPriceId.TryGetValue(newPlan.SecretsManager.StripeServiceAccountPlanId,
|
|
out var serviceAccounts);
|
|
|
|
if (hasServiceAccounts && serviceAccounts != null)
|
|
{
|
|
items.Add(new InvoiceSubscriptionDetailsItemOptions
|
|
{
|
|
Price = newPlan.SecretsManager.StripeServiceAccountPlanId,
|
|
Quantity = serviceAccounts.Quantity
|
|
});
|
|
}
|
|
}
|
|
|
|
options.SubscriptionDetails = new InvoiceSubscriptionDetailsOptions { Items = items };
|
|
|
|
var invoice = await stripeAdapter.InvoiceCreatePreviewAsync(options);
|
|
return GetAmounts(invoice);
|
|
}
|
|
});
|
|
|
|
public Task<BillingCommandResult<(decimal Tax, decimal Total)>> Run(
|
|
Organization organization,
|
|
OrganizationSubscriptionUpdate update)
|
|
=> HandleAsync<(decimal, decimal)>(async () =>
|
|
{
|
|
if (organization is not
|
|
{
|
|
GatewayCustomerId: not null,
|
|
GatewaySubscriptionId: not null
|
|
})
|
|
{
|
|
return new BadRequest("Organization does not have a subscription.");
|
|
}
|
|
|
|
var subscription = await stripeAdapter.SubscriptionGetAsync(organization.GatewaySubscriptionId,
|
|
new SubscriptionGetOptions { Expand = ["customer.tax_ids"] });
|
|
|
|
var options = GetBaseOptions(subscription.Customer,
|
|
organization.GetProductUsageType() == ProductUsageType.Business);
|
|
|
|
if (subscription.Customer.Discount != null)
|
|
{
|
|
options.Coupon = subscription.Customer.Discount.Coupon.Id;
|
|
}
|
|
|
|
var currentPlan = await pricingClient.GetPlanOrThrow(organization.PlanType);
|
|
|
|
var items = new List<InvoiceSubscriptionDetailsItemOptions>();
|
|
|
|
if (update.PasswordManager?.Seats != null)
|
|
{
|
|
items.Add(new InvoiceSubscriptionDetailsItemOptions
|
|
{
|
|
Price = currentPlan.HasNonSeatBasedPasswordManagerPlan()
|
|
? currentPlan.PasswordManager.StripePlanId
|
|
: currentPlan.PasswordManager.StripeSeatPlanId,
|
|
Quantity = update.PasswordManager.Seats
|
|
});
|
|
}
|
|
|
|
if (update.PasswordManager?.AdditionalStorage is > 0)
|
|
{
|
|
items.Add(new InvoiceSubscriptionDetailsItemOptions
|
|
{
|
|
Price = currentPlan.PasswordManager.StripeStoragePlanId,
|
|
Quantity = update.PasswordManager.AdditionalStorage
|
|
});
|
|
}
|
|
|
|
if (update.SecretsManager?.Seats is > 0)
|
|
{
|
|
items.Add(new InvoiceSubscriptionDetailsItemOptions
|
|
{
|
|
Price = currentPlan.SecretsManager.StripeSeatPlanId,
|
|
Quantity = update.SecretsManager.Seats
|
|
});
|
|
|
|
if (update.SecretsManager.AdditionalServiceAccounts is > 0)
|
|
{
|
|
items.Add(new InvoiceSubscriptionDetailsItemOptions
|
|
{
|
|
Price = currentPlan.SecretsManager.StripeServiceAccountPlanId,
|
|
Quantity = update.SecretsManager.AdditionalServiceAccounts
|
|
});
|
|
}
|
|
}
|
|
|
|
options.SubscriptionDetails = new InvoiceSubscriptionDetailsOptions { Items = items };
|
|
|
|
var invoice = await stripeAdapter.InvoiceCreatePreviewAsync(options);
|
|
return GetAmounts(invoice);
|
|
});
|
|
|
|
private static (decimal, decimal) GetAmounts(Invoice invoice) => (
|
|
Convert.ToDecimal(invoice.Tax) / 100,
|
|
Convert.ToDecimal(invoice.Total) / 100);
|
|
|
|
private static InvoiceCreatePreviewOptions GetBaseOptions(
|
|
OneOf<Customer, BillingAddress> addressChoice,
|
|
bool businessUse)
|
|
{
|
|
var country = addressChoice.Match(
|
|
customer => customer.Address.Country,
|
|
billingAddress => billingAddress.Country
|
|
);
|
|
|
|
var postalCode = addressChoice.Match(
|
|
customer => customer.Address.PostalCode,
|
|
billingAddress => billingAddress.PostalCode);
|
|
|
|
var options = new InvoiceCreatePreviewOptions
|
|
{
|
|
AutomaticTax = new InvoiceAutomaticTaxOptions { Enabled = true },
|
|
Currency = "usd",
|
|
CustomerDetails = new InvoiceCustomerDetailsOptions
|
|
{
|
|
Address = new AddressOptions { Country = country, PostalCode = postalCode },
|
|
TaxExempt = businessUse && country != CountryAbbreviations.UnitedStates
|
|
? TaxExempt.Reverse
|
|
: TaxExempt.None
|
|
}
|
|
};
|
|
|
|
var taxId = addressChoice.Match(
|
|
customer =>
|
|
{
|
|
var taxId = customer.TaxIds?.FirstOrDefault();
|
|
return taxId != null ? new TaxID(taxId.Type, taxId.Value) : null;
|
|
},
|
|
billingAddress => billingAddress.TaxId);
|
|
|
|
if (taxId == null)
|
|
{
|
|
return options;
|
|
}
|
|
|
|
options.CustomerDetails.TaxIds =
|
|
[
|
|
new InvoiceCustomerDetailsTaxIdOptions { Type = taxId.Code, Value = taxId.Value }
|
|
];
|
|
|
|
if (taxId.Code == TaxIdType.SpanishNIF)
|
|
{
|
|
options.CustomerDetails.TaxIds.Add(new InvoiceCustomerDetailsTaxIdOptions
|
|
{
|
|
Type = TaxIdType.EUVAT,
|
|
Value = $"ES{taxId.Value}"
|
|
});
|
|
}
|
|
|
|
return options;
|
|
}
|
|
}
|