Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
75 changes: 5 additions & 70 deletions src/Api/Billing/Controllers/AccountsController.cs
Original file line number Diff line number Diff line change
@@ -1,15 +1,11 @@
ο»Ώusing Bit.Api.Models.Request;
using Bit.Api.Models.Request.Accounts;
using Bit.Api.Models.Request;
using Bit.Api.Models.Response;
using Bit.Api.Utilities;
using Bit.Core;
using Bit.Core.Billing.Models;
using Bit.Core.Billing.Models.Business;
using Bit.Core.Billing.Services;
using Bit.Core.Billing.Subscriptions.Commands;
using Bit.Core.Exceptions;
using Bit.Core.Services;
using Bit.Core.Settings;
using Bit.Core.Utilities;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
Expand All @@ -19,59 +15,19 @@ namespace Bit.Api.Billing.Controllers;
[Route("accounts")]
[Authorize("Application")]
public class AccountsController(
IUserService userService,
IFeatureService featureService,
ILicensingService licensingService,
IReinstateSubscriptionCommand reinstateSubscriptionCommand) : Controller
IUserService userService) : Controller
{
// TODO: Remove with deletion of pm-29594-update-individual-subscription-page
[HttpGet("subscription")]
public async Task<SubscriptionResponseModel> GetSubscriptionAsync(
[FromServices] GlobalSettings globalSettings,
[FromServices] IStripePaymentService paymentService)
{
var user = await userService.GetUserByPrincipalAsync(User);
if (user == null)
{
throw new UnauthorizedAccessException();
}

// Only cloud-hosted users with payment gateways have subscription and discount information
if (!globalSettings.SelfHosted)
{
if (user.Gateway != null)
{
var subscriptionInfo = await paymentService.GetSubscriptionAsync(user);
var license = await userService.GenerateLicenseAsync(user, subscriptionInfo);
var claimsPrincipal = licensingService.GetClaimsPrincipalFromLicense(license);
return new SubscriptionResponseModel(user, subscriptionInfo, license, claimsPrincipal);
}
else
{
var license = await userService.GenerateLicenseAsync(user);
var claimsPrincipal = licensingService.GetClaimsPrincipalFromLicense(license);
return new SubscriptionResponseModel(user, null, license, claimsPrincipal);
}
}
else
{
return new SubscriptionResponseModel(user);
}
}

// TODO: Remove with deletion of pm-29594-update-individual-subscription-page
[HttpPost("storage")]
[SelfHosted(NotSelfHostedOnly = true)]
public async Task<PaymentResponseModel> PostStorageAsync([FromBody] StorageRequestModel model)
[SelfHosted(SelfHostedOnly = true)]
public async Task<SubscriptionResponseModel> GetSubscriptionAsync()
{
var user = await userService.GetUserByPrincipalAsync(User);
if (user == null)
{
throw new UnauthorizedAccessException();
}

var result = await userService.AdjustStorageAsync(user, model.StorageGbAdjustment!.Value);
return new PaymentResponseModel { Success = true, PaymentIntentClientSecret = result };
return new SubscriptionResponseModel(user);
}

/*
Expand Down Expand Up @@ -115,25 +71,4 @@ await subscriberService.CancelSubscription(user,
user.IsExpired(),
new OffboardingSurveyResponse { UserId = user.Id, Reason = request.Reason, Feedback = request.Feedback });
}

// TODO: Remove with deletion of pm-29594-update-individual-subscription-page
[HttpPost("reinstate-premium")]
[SelfHosted(NotSelfHostedOnly = true)]
public async Task PostReinstateAsync()
{
var user = await userService.GetUserByPrincipalAsync(User);
if (user == null)
{
throw new UnauthorizedAccessException();
}

if (featureService.IsEnabled(FeatureFlagKeys.PM32645_DeferPriceMigrationToRenewal))
{
(await reinstateSubscriptionCommand.Run(user)).GetValueOrThrow();
}
else
{
await userService.ReinstatePremiumAsync(user);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,6 @@ public async Task<IResult> GetLicenseAsync(
}

[HttpGet("subscription")]
[RequireFeature(FeatureFlagKeys.PM29594_UpdateIndividualSubscriptionPage)]
[InjectUser]
public async Task<IResult> GetSubscriptionAsync(
[BindNever] User user)
Expand All @@ -129,7 +128,6 @@ public async Task<IResult> GetSubscriptionAsync(
}

[HttpPost("subscription/reinstate")]
[RequireFeature(FeatureFlagKeys.PM29594_UpdateIndividualSubscriptionPage)]
[InjectUser]
public async Task<IResult> ReinstateSubscriptionAsync(
[BindNever] User user)
Expand All @@ -139,7 +137,6 @@ public async Task<IResult> ReinstateSubscriptionAsync(
}

[HttpPut("subscription/storage")]
[RequireFeature(FeatureFlagKeys.PM29594_UpdateIndividualSubscriptionPage)]
[InjectUser]
public async Task<IResult> UpdateSubscriptionStorageAsync(
[BindNever] User user,
Expand Down
69 changes: 69 additions & 0 deletions src/Api/Models/Response/BillingCustomerDiscount.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
using Bit.Core.Models.Business;

namespace Bit.Api.Models.Response;

/// <summary>
/// Customer discount information from Stripe billing.
/// </summary>
public class BillingCustomerDiscount
{
/// <summary>
/// The Stripe coupon ID (e.g., "cm3nHfO1").
/// </summary>
public string? Id { get; }

/// <summary>
/// Whether the discount is a recurring/perpetual discount with no expiration date.
/// <para>
/// This property is true only when the discount has no end date, meaning it applies
/// indefinitely to all future renewals. This is a product decision for Milestone 2
/// to only display perpetual discounts in the UI.
/// </para>
/// <para>
/// Note: This does NOT indicate whether the discount is "currently active" in the billing sense.
/// A discount with a future end date is functionally active and will be applied by Stripe,
/// but this property will be false because it has an expiration date.
/// </para>
/// </summary>
public bool Active { get; }

/// <summary>
/// Percentage discount applied to the subscription (e.g., 20.0 for 20% off).
/// Null if this is an amount-based discount.
/// </summary>
public decimal? PercentOff { get; }

/// <summary>
/// Fixed amount discount in USD (e.g., 14.00 for $14 off).
/// Converted from Stripe's cent-based values (1400 cents β†’ $14.00).
/// Null if this is a percentage-based discount.
/// Note: Stripe stores amounts in the smallest currency unit. This value is always in USD.
/// </summary>
public decimal? AmountOff { get; }

/// <summary>
/// List of Stripe product IDs that this discount applies to (e.g., ["prod_premium", "prod_families"]).
/// <para>
/// Null: discount applies to all products with no restrictions (AppliesTo not specified in Stripe).
/// Empty list: discount restricted to zero products (edge case - AppliesTo.Products = [] in Stripe).
/// Non-empty list: discount applies only to the specified product IDs.
/// </para>
/// </summary>
public IReadOnlyList<string>? AppliesTo { get; }

/// <summary>
/// Creates a BillingCustomerDiscount from a SubscriptionInfo.BillingCustomerDiscount.
/// </summary>
/// <param name="discount">The discount to convert. Must not be null.</param>
/// <exception cref="ArgumentNullException">Thrown when discount is null.</exception>
public BillingCustomerDiscount(SubscriptionInfo.BillingCustomerDiscount discount)
{
ArgumentNullException.ThrowIfNull(discount);

Id = discount.Id;
Active = discount.Active;
PercentOff = discount.PercentOff;
AmountOff = discount.AmountOff;
AppliesTo = discount.AppliesTo;
}
}
62 changes: 62 additions & 0 deletions src/Api/Models/Response/BillingSubscription.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
using Bit.Core.Models.Business;

namespace Bit.Api.Models.Response;

public class BillingSubscription
{
public BillingSubscription(SubscriptionInfo.BillingSubscription sub)
{
Status = sub.Status;
TrialStartDate = sub.TrialStartDate;
TrialEndDate = sub.TrialEndDate;
PeriodStartDate = sub.PeriodStartDate;
PeriodEndDate = sub.PeriodEndDate;
CancelledDate = sub.CancelledDate;
CancelAtEndDate = sub.CancelAtEndDate;
Cancelled = sub.Cancelled;
if (sub.Items != null)
{
Items = sub.Items.Select(i => new BillingSubscriptionItem(i));
}
CollectionMethod = sub.CollectionMethod;
SuspensionDate = sub.SuspensionDate;
UnpaidPeriodEndDate = sub.UnpaidPeriodEndDate;
GracePeriod = sub.GracePeriod;
}

public DateTime? TrialStartDate { get; set; }
public DateTime? TrialEndDate { get; set; }
public DateTime? PeriodStartDate { get; set; }
public DateTime? PeriodEndDate { get; set; }
public DateTime? CancelledDate { get; set; }
public bool CancelAtEndDate { get; set; }
public string? Status { get; set; }
public bool Cancelled { get; set; }
public IEnumerable<BillingSubscriptionItem> Items { get; set; } = new List<BillingSubscriptionItem>();
public string? CollectionMethod { get; set; }
public DateTime? SuspensionDate { get; set; }
public DateTime? UnpaidPeriodEndDate { get; set; }
public int? GracePeriod { get; set; }

public class BillingSubscriptionItem
{
public BillingSubscriptionItem(SubscriptionInfo.BillingSubscription.BillingSubscriptionItem item)
{
ProductId = item.ProductId;
Name = item.Name;
Amount = item.Amount;
Interval = item.Interval;
Quantity = item.Quantity;
SponsoredSubscriptionItem = item.SponsoredSubscriptionItem;
AddonSubscriptionItem = item.AddonSubscriptionItem;
}

public string? ProductId { get; set; }
public string? Name { get; set; }
public decimal Amount { get; set; }
public int Quantity { get; set; }
public string? Interval { get; set; }
public bool SponsoredSubscriptionItem { get; set; }
public bool AddonSubscriptionItem { get; set; }
}
}
15 changes: 15 additions & 0 deletions src/Api/Models/Response/BillingSubscriptionUpcomingInvoice.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
using Bit.Core.Models.Business;

namespace Bit.Api.Models.Response;

public class BillingSubscriptionUpcomingInvoice
{
public BillingSubscriptionUpcomingInvoice(SubscriptionInfo.BillingUpcomingInvoice inv)
{
Amount = inv.Amount;
Date = inv.Date;
}

public decimal? Amount { get; set; }
public DateTime? Date { get; set; }
}
Loading
Loading