Skip to content
10 changes: 10 additions & 0 deletions src/Core/Billing/Constants/StripeConstants.cs
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,16 @@ public static class CancellationOrigins
public const string UnpaidSubscription = "unpaid_subscription";
}

/// <summary>
/// The values carried in <see cref="MetadataKeys.TrialInitiationPath"/>.
/// </summary>
public static class TrialInitiationPaths
{
public const string MarketingInitiated = "marketing-initiated";
public const string ProductInitiated = "product-initiated";
public const string SalesAssisted = "sales-assisted";
}

public static class PaymentBehavior
{
public const string DefaultIncomplete = "default_incomplete";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ public record OrganizationWarnings
public record FreeTrialWarning
{
public int RemainingTrialDays { get; set; }
public bool IsSalesAssisted { get; set; }
}

public record InactiveSubscriptionWarning
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,12 @@ await subscriberService.GetSubscription(organization,

var remainingTrialDays = (int)Math.Ceiling((subscription.TrialEnd.Value - now).TotalDays);

return new FreeTrialWarning { RemainingTrialDays = remainingTrialDays };
var isSalesAssisted = subscription.Metadata.TryGetValue(
StripeConstants.MetadataKeys.TrialInitiationPath,
out var trialInitiationPath) &&
trialInitiationPath == StripeConstants.TrialInitiationPaths.SalesAssisted;

return new FreeTrialWarning { RemainingTrialDays = remainingTrialDays, IsSalesAssisted = isSalesAssisted };
}

private async Task<InactiveSubscriptionWarning?> GetInactiveSubscriptionWarningAsync(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,12 @@ public class OrganizationBillingService(
ISubscriptionDiscountService subscriptionDiscountService,
ITaxService taxService) : IOrganizationBillingService
{
// Must match `InitiationPath.SalesAssistedTrialFromAdminPortal` in the clients repo.
private const string _salesAssistedTrialInitiationPath = "Sales assisted trial from admin portal";

// Matched as a substring of the client's `InitiationPath` marketing-trial values.
private const string _marketingTrialInitiationPathSegment = "trial from marketing website";

public async Task Finalize(OrganizationSale sale)
{
var (organization, customerSetup, subscriptionSetup, owner) = sale;
Expand Down Expand Up @@ -452,11 +458,15 @@ private async Task<Subscription> CreateSubscriptionAsync(
Items = subscriptionItemOptionsList,
Metadata = new Dictionary<string, string>
{
["organizationId"] = organization.Id.ToString(),
["trialInitiationPath"] = !string.IsNullOrEmpty(subscriptionSetup.InitiationPath) &&
subscriptionSetup.InitiationPath.Contains("trial from marketing website")
? "marketing-initiated"
: "product-initiated"
[StripeConstants.MetadataKeys.OrganizationId] = organization.Id.ToString(),
[StripeConstants.MetadataKeys.TrialInitiationPath] = subscriptionSetup.InitiationPath switch
{
var path when !string.IsNullOrEmpty(path) && path.Contains(_marketingTrialInitiationPathSegment)
=> StripeConstants.TrialInitiationPaths.MarketingInitiated,
_salesAssistedTrialInitiationPath
=> StripeConstants.TrialInitiationPaths.SalesAssisted,
_ => StripeConstants.TrialInitiationPaths.ProductInitiated
}
},
OffSession = true,
TrialPeriodDays = subscriptionSetup.SkipTrial
Expand Down Expand Up @@ -499,6 +509,5 @@ private async Task<Subscription> CreateSubscriptionAsync(
return subscription;
}


#endregion
}
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ public async Task Run_Has_FreeTrialWarning(
InvoiceSettings = new CustomerInvoiceSettings(),
Metadata = new Dictionary<string, string>()
},
Metadata = new Dictionary<string, string>(),
TestClock = new TestClock
{
FrozenTime = now
Expand All @@ -87,7 +88,48 @@ public async Task Run_Has_FreeTrialWarning(

Assert.True(response is
{
FreeTrial.RemainingTrialDays: 7
FreeTrial: { RemainingTrialDays: 7, IsSalesAssisted: false }
});
}

[Theory, BitAutoData]
public async Task Run_Has_FreeTrialWarning_SalesAssisted(
Organization organization,
SutProvider<GetOrganizationWarningsQuery> sutProvider)
{
var now = DateTime.UtcNow;

sutProvider.GetDependency<ISubscriberService>()
.GetSubscription(organization, Arg.Is<SubscriptionGetOptions>(options =>
options.Expand.SequenceEqual(_requiredExpansions)
))
.Returns(new Subscription
{
Status = SubscriptionStatus.Trialing,
TrialEnd = now.AddDays(7),
Customer = new Customer
{
InvoiceSettings = new CustomerInvoiceSettings(),
Metadata = new Dictionary<string, string>()
},
Metadata = new Dictionary<string, string>
{
[MetadataKeys.TrialInitiationPath] = TrialInitiationPaths.SalesAssisted
},
TestClock = new TestClock
{
FrozenTime = now
}
});

sutProvider.GetDependency<ICurrentContext>().EditSubscription(organization.Id).Returns(true);
sutProvider.GetDependency<IHasPaymentMethodQuery>().Run(organization).Returns(false);

var response = await sutProvider.Sut.Run(organization);

Assert.True(response is
{
FreeTrial: { RemainingTrialDays: 7, IsSalesAssisted: true }
});
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,88 @@ await sutProvider.GetDependency<IStripeAdapter>()
Assert.Equal("cancel", capturedOptions.TrialSettings.EndBehavior.MissingPaymentMethod);
}

// Literal strings on purpose: the initiation path sentence must match the clients-repo
// InitiationPath enum, and the metadata values are persisted in Stripe β€” changing either
// should fail this test.
[Theory]
[BitAutoData("Password Manager trial from marketing website", "marketing-initiated")]
[BitAutoData("Secrets Manager trial from marketing website", "marketing-initiated")]
[BitAutoData("Sales assisted trial from admin portal", "sales-assisted")]
[BitAutoData("New organization creation in-product", "product-initiated")]
[BitAutoData((string?)null, "product-initiated")]
public async Task Finalize_SetsTrialInitiationPathMetadata(
string? initiationPath,
string expectedMetadataValue,
Organization organization,
User owner,
SutProvider<OrganizationBillingService> sutProvider)
{
// Arrange
var plan = MockPlans.Get(PlanType.TeamsAnnually);
organization.PlanType = PlanType.TeamsAnnually;
organization.GatewayCustomerId = "cus_test123";
organization.GatewaySubscriptionId = null;

var subscriptionSetup = new SubscriptionSetup
{
PlanType = PlanType.TeamsAnnually,
PasswordManagerOptions = new SubscriptionSetup.PasswordManager
{
Seats = 5,
Storage = null,
PremiumAccess = false
},
SecretsManagerOptions = null,
SkipTrial = false,
InitiationPath = initiationPath
};

var sale = new OrganizationSale
{
Organization = organization,
SubscriptionSetup = subscriptionSetup,
Owner = owner
};

sutProvider.GetDependency<IPricingClient>()
.GetPlanOrThrow(PlanType.TeamsAnnually)
.Returns(plan);

sutProvider.GetDependency<IHasPaymentMethodQuery>()
.Run(organization)
.Returns(false);

var customer = new Customer
{
Id = "cus_test123",
Tax = new CustomerTax { AutomaticTax = StripeConstants.AutomaticTaxStatus.Supported }
};

sutProvider.GetDependency<ISubscriberService>()
.GetCustomerOrThrow(organization, Arg.Any<CustomerGetOptions>())
.Returns(customer);

SubscriptionCreateOptions capturedOptions = null;
sutProvider.GetDependency<IStripeAdapter>()
.CreateSubscriptionAsync(Arg.Do<SubscriptionCreateOptions>(options => capturedOptions = options))
.Returns(new Subscription
{
Id = "sub_test123",
Status = StripeConstants.SubscriptionStatus.Trialing
});

sutProvider.GetDependency<IOrganizationRepository>()
.ReplaceAsync(organization)
.Returns(Task.CompletedTask);

// Act
await sutProvider.Sut.Finalize(sale);

// Assert
Assert.NotNull(capturedOptions);
Assert.Equal(expectedMetadataValue, capturedOptions.Metadata["trialInitiationPath"]);
}

[Theory, BitAutoData]
public async Task NoPaymentMethodButNoTrial_DoesNotSetMissingPaymentMethodBehavior(
Organization organization,
Expand Down
Loading