Skip to content
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
ο»Ώusing System.ComponentModel.DataAnnotations;
using Bit.Api.Billing.Models.Requests.Payment;
using Bit.Core.Auth.Attributes;
using Bit.Core.Billing.Payment.Models;
using Bit.Core.Billing.Premium.Models;

Expand All @@ -18,6 +19,9 @@ public class PremiumCloudHostedSubscriptionRequest : IValidatableObject

public string[]? Coupons { get; set; }

[MarketingInitiativeValidation]
public string? FromMarketing { get; set; }

public PremiumSubscriptionPurchase ToDomain()
{
// Check if TokenizedPaymentMethod or NonTokenizedPaymentMethod is provided.
Expand All @@ -35,7 +39,8 @@ public PremiumSubscriptionPurchase ToDomain()
PaymentMethod = paymentMethod,
BillingAddress = billingAddress,
AdditionalStorageGb = AdditionalStorageGb,
Coupons = Coupons
Coupons = Coupons,
FromMarketing = FromMarketing
};
}

Expand Down
1 change: 1 addition & 0 deletions src/Core/Billing/Constants/StripeConstants.cs
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@ public static class MetadataKeys
public const string StorageReconciled2025 = "storage_reconciled_2025";
public const string OriginatingPlatform = "originatingPlatform";
public const string OriginatingAppVersion = "originatingAppVersion";
public const string TrialInitiationPath = "trialInitiationPath";
}

public static class PaymentBehavior
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
ο»Ώusing Bit.Core.Billing.Commands;
ο»Ώusing Bit.Core.Auth.Models.Api.Request.Accounts;
using Bit.Core.Billing.Commands;
using Bit.Core.Billing.Constants;
using Bit.Core.Billing.Enums;
using Bit.Core.Billing.Extensions;
Expand Down Expand Up @@ -132,7 +133,7 @@ public Task<BillingCommandResult<None>> Run(

customer = await ReconcileBillingLocationAsync(customer, subscriptionPurchase.BillingAddress);

var subscription = await CreateSubscriptionAsync(user.Id, customer, premiumPlan, subscriptionPurchase.AdditionalStorageGb > 0 ? subscriptionPurchase.AdditionalStorageGb : null, validatedCoupons);
var subscription = await CreateSubscriptionAsync(user.Id, customer, premiumPlan, subscriptionPurchase.AdditionalStorageGb > 0 ? subscriptionPurchase.AdditionalStorageGb : null, validatedCoupons, subscriptionPurchase.FromMarketing);

subscriptionPurchase.PaymentMethod.Switch(
tokenized =>
Expand Down Expand Up @@ -312,7 +313,8 @@ private async Task<Subscription> CreateSubscriptionAsync(
Customer customer,
Pricing.Premium.Plan premiumPlan,
int? storage,
IReadOnlyList<string> validatedCoupons)
IReadOnlyList<string> validatedCoupons,
string? fromMarketing)
{

var subscriptionItemOptionsList = new List<SubscriptionItemOptions>
Expand Down Expand Up @@ -346,7 +348,10 @@ private async Task<Subscription> CreateSubscriptionAsync(
Items = subscriptionItemOptionsList,
Metadata = new Dictionary<string, string>
{
[MetadataKeys.UserId] = userId.ToString()
[MetadataKeys.UserId] = userId.ToString(),
[MetadataKeys.TrialInitiationPath] = fromMarketing == MarketingInitiativeConstants.Premium
? "marketing-initiated"
: "product-initiated"
},
PaymentBehavior = usingPayPal
? PaymentBehavior.DefaultIncomplete
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,4 +8,5 @@ public record PremiumSubscriptionPurchase
public required BillingAddress BillingAddress { get; init; }
public short? AdditionalStorageGb { get; init; }
public string[]? Coupons { get; init; }
public string? FromMarketing { get; init; }
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
ο»Ώusing Bit.Core.Billing;
ο»Ώusing Bit.Core.Auth.Models.Api.Request.Accounts;
using Bit.Core.Billing;
using Bit.Core.Billing.Constants;
using Bit.Core.Billing.Enums;
using Bit.Core.Billing.Extensions;
Expand Down Expand Up @@ -1358,6 +1359,86 @@ await _stripeAdapter.Received(1).CreateSubscriptionAsync(Arg.Is<SubscriptionCrea
opts.Discounts == null));
}

[Theory, BitAutoData]
public async Task Run_WithMarketingFromMarketing_SetsMarketingInitiatedMetadata(
User user,
TokenizedPaymentMethod paymentMethod,
BillingAddress billingAddress)
{
// Arrange
user.Premium = false;
user.GatewayCustomerId = null;
user.Email = "test@example.com";
paymentMethod.Type = TokenizablePaymentMethodType.Card;
paymentMethod.Token = "card_token_123";

var subscriptionPurchase = new PremiumSubscriptionPurchase
{
PaymentMethod = paymentMethod,
BillingAddress = billingAddress,
AdditionalStorageGb = 0,
Coupons = null,
FromMarketing = MarketingInitiativeConstants.Premium
};

var mockCustomer = CreateMockCustomer();
var mockSubscription = CreateMockActiveSubscription();

SubscriptionCreateOptions? capturedOptions = null;
_stripeAdapter.CreateCustomerAsync(Arg.Any<CustomerCreateOptions>()).Returns(mockCustomer);
_stripeAdapter.UpdateCustomerAsync(Arg.Any<string>(), Arg.Any<CustomerUpdateOptions>()).Returns(mockCustomer);
_stripeAdapter.CreateSubscriptionAsync(Arg.Do<SubscriptionCreateOptions>(opts => capturedOptions = opts))
.Returns(mockSubscription);

// Act
var result = await _command.Run(user, subscriptionPurchase);

// Assert
Assert.True(result.IsT0);
Assert.NotNull(capturedOptions);
Assert.Equal("marketing-initiated", capturedOptions.Metadata[StripeConstants.MetadataKeys.TrialInitiationPath]);
}

[Theory, BitAutoData]
public async Task Run_WithoutFromMarketing_SetsProductInitiatedMetadata(
User user,
TokenizedPaymentMethod paymentMethod,
BillingAddress billingAddress)
{
// Arrange
user.Premium = false;
user.GatewayCustomerId = null;
user.Email = "test@example.com";
paymentMethod.Type = TokenizablePaymentMethodType.Card;
paymentMethod.Token = "card_token_123";

var subscriptionPurchase = new PremiumSubscriptionPurchase
{
PaymentMethod = paymentMethod,
BillingAddress = billingAddress,
AdditionalStorageGb = 0,
Coupons = null,
FromMarketing = null
};

var mockCustomer = CreateMockCustomer();
var mockSubscription = CreateMockActiveSubscription();

SubscriptionCreateOptions? capturedOptions = null;
_stripeAdapter.CreateCustomerAsync(Arg.Any<CustomerCreateOptions>()).Returns(mockCustomer);
_stripeAdapter.UpdateCustomerAsync(Arg.Any<string>(), Arg.Any<CustomerUpdateOptions>()).Returns(mockCustomer);
_stripeAdapter.CreateSubscriptionAsync(Arg.Do<SubscriptionCreateOptions>(opts => capturedOptions = opts))
.Returns(mockSubscription);

// Act
var result = await _command.Run(user, subscriptionPurchase);

// Assert
Assert.True(result.IsT0);
Assert.NotNull(capturedOptions);
Assert.Equal("product-initiated", capturedOptions.Metadata[StripeConstants.MetadataKeys.TrialInitiationPath]);
}

[Theory, BitAutoData]
public async Task Run_WithEmptyCouponsArray_CreatesSubscriptionWithoutDiscount(
User user,
Expand Down
Loading