diff --git a/src/Core/Billing/Enums/DiscountAudienceType.cs b/src/Core/Billing/Enums/DiscountAudienceType.cs
new file mode 100644
index 0000000000..98ebd9163d
--- /dev/null
+++ b/src/Core/Billing/Enums/DiscountAudienceType.cs
@@ -0,0 +1,13 @@
+namespace Bit.Core.Billing.Enums;
+
+///
+/// Defines the target audience for subscription discounts using an extensible strategy pattern.
+/// Each audience type maps to specific eligibility rules implemented via IDiscountAudienceFilter.
+///
+public enum DiscountAudienceType
+{
+ ///
+ /// Discount applies to users who have never had a subscription before.
+ ///
+ UserHasNoPreviousSubscriptions = 0
+}
diff --git a/src/Core/Billing/Subscriptions/Entities/SubscriptionDiscount.cs b/src/Core/Billing/Subscriptions/Entities/SubscriptionDiscount.cs
new file mode 100644
index 0000000000..03165f6b05
--- /dev/null
+++ b/src/Core/Billing/Subscriptions/Entities/SubscriptionDiscount.cs
@@ -0,0 +1,48 @@
+#nullable enable
+
+using System.ComponentModel.DataAnnotations;
+using Bit.Core.Billing.Enums;
+using Bit.Core.Entities;
+using Bit.Core.Utilities;
+
+namespace Bit.Core.Billing.Subscriptions.Entities;
+
+public class SubscriptionDiscount : ITableObject, IRevisable, IValidatableObject
+{
+ public Guid Id { get; set; }
+ [MaxLength(50)]
+ public string StripeCouponId { get; set; } = null!;
+ public ICollection? StripeProductIds { get; set; }
+ public decimal? PercentOff { get; set; }
+ public long? AmountOff { get; set; }
+ [MaxLength(10)]
+ public string? Currency { get; set; }
+ [MaxLength(20)]
+ public string Duration { get; set; } = null!;
+ public int? DurationInMonths { get; set; }
+ [MaxLength(100)]
+ public string? Name { get; set; }
+ public DateTime StartDate { get; set; }
+ public DateTime EndDate { get; set; }
+ public DiscountAudienceType AudienceType { get; set; }
+ public DateTime CreationDate { get; set; } = DateTime.UtcNow;
+ public DateTime RevisionDate { get; set; } = DateTime.UtcNow;
+
+ public void SetNewId()
+ {
+ if (Id == default)
+ {
+ Id = CoreHelpers.GenerateComb();
+ }
+ }
+
+ public IEnumerable Validate(ValidationContext validationContext)
+ {
+ if (EndDate < StartDate)
+ {
+ yield return new ValidationResult(
+ "EndDate must be greater than or equal to StartDate.",
+ new[] { nameof(EndDate) });
+ }
+ }
+}
diff --git a/src/Core/Billing/Subscriptions/Repositories/ISubscriptionDiscountRepository.cs b/src/Core/Billing/Subscriptions/Repositories/ISubscriptionDiscountRepository.cs
new file mode 100644
index 0000000000..93accbd9e7
--- /dev/null
+++ b/src/Core/Billing/Subscriptions/Repositories/ISubscriptionDiscountRepository.cs
@@ -0,0 +1,23 @@
+#nullable enable
+
+using Bit.Core.Billing.Subscriptions.Entities;
+using Bit.Core.Repositories;
+
+namespace Bit.Core.Billing.Subscriptions.Repositories;
+
+public interface ISubscriptionDiscountRepository : IRepository
+{
+ ///
+ /// Retrieves all active subscription discounts that are currently within their valid date range.
+ /// A discount is considered active if the current UTC date falls between StartDate (inclusive) and EndDate (inclusive).
+ ///
+ /// A collection of active subscription discounts.
+ Task> GetActiveDiscountsAsync();
+
+ ///
+ /// Retrieves a subscription discount by its Stripe coupon ID.
+ ///
+ /// The Stripe coupon ID to search for.
+ /// The subscription discount if found; otherwise, null.
+ Task GetByStripeCouponIdAsync(string stripeCouponId);
+}
diff --git a/src/Infrastructure.Dapper/Billing/Repositories/SubscriptionDiscountRepository.cs b/src/Infrastructure.Dapper/Billing/Repositories/SubscriptionDiscountRepository.cs
new file mode 100644
index 0000000000..72fa8d7d4e
--- /dev/null
+++ b/src/Infrastructure.Dapper/Billing/Repositories/SubscriptionDiscountRepository.cs
@@ -0,0 +1,39 @@
+using System.Data;
+using Bit.Core.Billing.Subscriptions.Entities;
+using Bit.Core.Billing.Subscriptions.Repositories;
+using Bit.Core.Settings;
+using Bit.Infrastructure.Dapper.Repositories;
+using Dapper;
+using Microsoft.Data.SqlClient;
+
+namespace Bit.Infrastructure.Dapper.Billing.Repositories;
+
+public class SubscriptionDiscountRepository(
+ GlobalSettings globalSettings)
+ : Repository(
+ globalSettings.SqlServer.ConnectionString,
+ globalSettings.SqlServer.ReadOnlyConnectionString), ISubscriptionDiscountRepository
+{
+ public async Task> GetActiveDiscountsAsync()
+ {
+ using var sqlConnection = new SqlConnection(ReadOnlyConnectionString);
+
+ var results = await sqlConnection.QueryAsync(
+ "[dbo].[SubscriptionDiscount_ReadActive]",
+ commandType: CommandType.StoredProcedure);
+
+ return results.ToArray();
+ }
+
+ public async Task GetByStripeCouponIdAsync(string stripeCouponId)
+ {
+ using var sqlConnection = new SqlConnection(ReadOnlyConnectionString);
+
+ var result = await sqlConnection.QueryFirstOrDefaultAsync(
+ "[dbo].[SubscriptionDiscount_ReadByStripeCouponId]",
+ new { StripeCouponId = stripeCouponId },
+ commandType: CommandType.StoredProcedure);
+
+ return result;
+ }
+}
diff --git a/src/Infrastructure.Dapper/DapperServiceCollectionExtensions.cs b/src/Infrastructure.Dapper/DapperServiceCollectionExtensions.cs
index dcb0dc1306..4055281352 100644
--- a/src/Infrastructure.Dapper/DapperServiceCollectionExtensions.cs
+++ b/src/Infrastructure.Dapper/DapperServiceCollectionExtensions.cs
@@ -2,6 +2,7 @@
using Bit.Core.Auth.Repositories;
using Bit.Core.Billing.Organizations.Repositories;
using Bit.Core.Billing.Providers.Repositories;
+using Bit.Core.Billing.Subscriptions.Repositories;
using Bit.Core.Dirt.Reports.Repositories;
using Bit.Core.Dirt.Repositories;
using Bit.Core.KeyManagement.Repositories;
@@ -65,6 +66,7 @@ public static class DapperServiceCollectionExtensions
services.AddSingleton();
services.AddSingleton();
services.AddSingleton();
+ services.AddSingleton();
services.AddSingleton();
services.AddSingleton();
services
diff --git a/src/Infrastructure.Dapper/Repositories/BaseRepository.cs b/src/Infrastructure.Dapper/Repositories/BaseRepository.cs
index a5a8cd0ee1..317e7ebbb3 100644
--- a/src/Infrastructure.Dapper/Repositories/BaseRepository.cs
+++ b/src/Infrastructure.Dapper/Repositories/BaseRepository.cs
@@ -9,6 +9,7 @@ public abstract class BaseRepository
static BaseRepository()
{
SqlMapper.AddTypeHandler(new DateTimeHandler());
+ SqlMapper.AddTypeHandler(new JsonCollectionTypeHandler());
}
public BaseRepository(string connectionString, string readOnlyConnectionString)
diff --git a/src/Infrastructure.Dapper/Repositories/JsonCollectionTypeHandler.cs b/src/Infrastructure.Dapper/Repositories/JsonCollectionTypeHandler.cs
new file mode 100644
index 0000000000..1f5455f7b7
--- /dev/null
+++ b/src/Infrastructure.Dapper/Repositories/JsonCollectionTypeHandler.cs
@@ -0,0 +1,31 @@
+using System.Data;
+using System.Text.Json;
+using Dapper;
+
+#nullable enable
+
+namespace Bit.Infrastructure.Dapper.Repositories;
+
+public class JsonCollectionTypeHandler : SqlMapper.TypeHandler?>
+{
+ public override void SetValue(IDbDataParameter parameter, ICollection? value)
+ {
+ parameter.Value = value == null ? (object)DBNull.Value : JsonSerializer.Serialize(value);
+ }
+
+ public override ICollection? Parse(object value)
+ {
+ if (value == null || value is DBNull)
+ {
+ return null;
+ }
+
+ var json = value.ToString();
+ if (string.IsNullOrWhiteSpace(json))
+ {
+ return null;
+ }
+
+ return JsonSerializer.Deserialize>(json);
+ }
+}
diff --git a/src/Infrastructure.EntityFramework/Billing/Configurations/SubscriptionDiscountEntityTypeConfiguration.cs b/src/Infrastructure.EntityFramework/Billing/Configurations/SubscriptionDiscountEntityTypeConfiguration.cs
new file mode 100644
index 0000000000..c2c7eb86f2
--- /dev/null
+++ b/src/Infrastructure.EntityFramework/Billing/Configurations/SubscriptionDiscountEntityTypeConfiguration.cs
@@ -0,0 +1,42 @@
+using System.Text.Json;
+using Bit.Infrastructure.EntityFramework.Billing.Models;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.ChangeTracking;
+using Microsoft.EntityFrameworkCore.Metadata.Builders;
+
+namespace Bit.Infrastructure.EntityFramework.Billing.Configurations;
+
+public class SubscriptionDiscountEntityTypeConfiguration : IEntityTypeConfiguration
+{
+ public void Configure(EntityTypeBuilder builder)
+ {
+ builder
+ .Property(t => t.Id)
+ .ValueGeneratedNever();
+
+ builder
+ .HasIndex(sd => sd.StripeCouponId)
+ .IsUnique();
+
+ builder
+ .Property(sd => sd.StripeProductIds)
+ .HasConversion(
+ v => v == null ? null : JsonSerializer.Serialize(v, (JsonSerializerOptions?)null),
+ v => v == null ? null : JsonSerializer.Deserialize>(v, (JsonSerializerOptions?)null),
+ new ValueComparer?>(
+ (c1, c2) => (c1 == null && c2 == null) || (c1 != null && c2 != null && c1.SequenceEqual(c2)),
+ c => c == null ? 0 : c.Aggregate(0, (a, v) => HashCode.Combine(a, v.GetHashCode())),
+ c => c == null ? null : c.ToList()));
+
+ builder
+ .Property(sd => sd.PercentOff)
+ .HasPrecision(5, 2);
+
+ builder
+ .HasIndex(sd => new { sd.StartDate, sd.EndDate })
+ .IsClustered(false)
+ .HasDatabaseName("IX_SubscriptionDiscount_DateRange");
+
+ builder.ToTable(nameof(SubscriptionDiscount));
+ }
+}
diff --git a/src/Infrastructure.EntityFramework/Billing/Models/SubscriptionDiscount.cs b/src/Infrastructure.EntityFramework/Billing/Models/SubscriptionDiscount.cs
new file mode 100644
index 0000000000..0cb5d9532a
--- /dev/null
+++ b/src/Infrastructure.EntityFramework/Billing/Models/SubscriptionDiscount.cs
@@ -0,0 +1,18 @@
+#nullable enable
+
+using AutoMapper;
+
+namespace Bit.Infrastructure.EntityFramework.Billing.Models;
+
+// ReSharper disable once ClassWithVirtualMembersNeverInherited.Global
+public class SubscriptionDiscount : Core.Billing.Subscriptions.Entities.SubscriptionDiscount
+{
+}
+
+public class SubscriptionDiscountMapperProfile : Profile
+{
+ public SubscriptionDiscountMapperProfile()
+ {
+ CreateMap().ReverseMap();
+ }
+}
diff --git a/src/Infrastructure.EntityFramework/Billing/Repositories/SubscriptionDiscountRepository.cs b/src/Infrastructure.EntityFramework/Billing/Repositories/SubscriptionDiscountRepository.cs
new file mode 100644
index 0000000000..6ddcd65f27
--- /dev/null
+++ b/src/Infrastructure.EntityFramework/Billing/Repositories/SubscriptionDiscountRepository.cs
@@ -0,0 +1,51 @@
+using AutoMapper;
+using Bit.Core.Billing.Subscriptions.Entities;
+using Bit.Core.Billing.Subscriptions.Repositories;
+using Bit.Infrastructure.EntityFramework.Repositories;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.DependencyInjection;
+using EFSubscriptionDiscount = Bit.Infrastructure.EntityFramework.Billing.Models.SubscriptionDiscount;
+
+namespace Bit.Infrastructure.EntityFramework.Billing.Repositories;
+
+public class SubscriptionDiscountRepository(
+ IMapper mapper,
+ IServiceScopeFactory serviceScopeFactory)
+ : Repository(
+ serviceScopeFactory,
+ mapper,
+ context => context.SubscriptionDiscounts), ISubscriptionDiscountRepository
+{
+ public async Task> GetActiveDiscountsAsync()
+ {
+ using var serviceScope = ServiceScopeFactory.CreateScope();
+
+ var databaseContext = GetDatabaseContext(serviceScope);
+
+ var query =
+ from subscriptionDiscount in databaseContext.SubscriptionDiscounts
+ where subscriptionDiscount.StartDate <= DateTime.UtcNow
+ && subscriptionDiscount.EndDate >= DateTime.UtcNow
+ select subscriptionDiscount;
+
+ var results = await query.ToArrayAsync();
+
+ return Mapper.Map>(results);
+ }
+
+ public async Task GetByStripeCouponIdAsync(string stripeCouponId)
+ {
+ using var serviceScope = ServiceScopeFactory.CreateScope();
+
+ var databaseContext = GetDatabaseContext(serviceScope);
+
+ var query =
+ from subscriptionDiscount in databaseContext.SubscriptionDiscounts
+ where subscriptionDiscount.StripeCouponId == stripeCouponId
+ select subscriptionDiscount;
+
+ var result = await query.FirstOrDefaultAsync();
+
+ return result == null ? null : Mapper.Map(result);
+ }
+}
diff --git a/src/Infrastructure.EntityFramework/EntityFrameworkServiceCollectionExtensions.cs b/src/Infrastructure.EntityFramework/EntityFrameworkServiceCollectionExtensions.cs
index 320cb9436d..84a370b723 100644
--- a/src/Infrastructure.EntityFramework/EntityFrameworkServiceCollectionExtensions.cs
+++ b/src/Infrastructure.EntityFramework/EntityFrameworkServiceCollectionExtensions.cs
@@ -2,6 +2,7 @@
using Bit.Core.Auth.Repositories;
using Bit.Core.Billing.Organizations.Repositories;
using Bit.Core.Billing.Providers.Repositories;
+using Bit.Core.Billing.Subscriptions.Repositories;
using Bit.Core.Dirt.Reports.Repositories;
using Bit.Core.Dirt.Repositories;
using Bit.Core.Enums;
@@ -102,6 +103,7 @@ public static class EntityFrameworkServiceCollectionExtensions
services.AddSingleton();
services.AddSingleton();
services.AddSingleton();
+ services.AddSingleton();
services.AddSingleton();
services.AddSingleton();
services
diff --git a/src/Infrastructure.EntityFramework/Repositories/DatabaseContext.cs b/src/Infrastructure.EntityFramework/Repositories/DatabaseContext.cs
index 3f638f88e5..8821f39097 100644
--- a/src/Infrastructure.EntityFramework/Repositories/DatabaseContext.cs
+++ b/src/Infrastructure.EntityFramework/Repositories/DatabaseContext.cs
@@ -79,6 +79,7 @@ public class DatabaseContext : DbContext
public DbSet WebAuthnCredentials { get; set; }
public DbSet ProviderPlans { get; set; }
public DbSet ProviderInvoiceItems { get; set; }
+ public DbSet SubscriptionDiscounts { get; set; }
public DbSet Notifications { get; set; }
public DbSet NotificationStatuses { get; set; }
public DbSet ClientOrganizationMigrationRecords { get; set; }
diff --git a/src/Sql/dbo/Stored Procedures/SubscriptionDiscount_Create.sql b/src/Sql/dbo/Stored Procedures/SubscriptionDiscount_Create.sql
new file mode 100644
index 0000000000..ad91dea631
--- /dev/null
+++ b/src/Sql/dbo/Stored Procedures/SubscriptionDiscount_Create.sql
@@ -0,0 +1,54 @@
+CREATE PROCEDURE [dbo].[SubscriptionDiscount_Create]
+ @Id UNIQUEIDENTIFIER OUTPUT,
+ @StripeCouponId VARCHAR(50),
+ @StripeProductIds NVARCHAR(MAX),
+ @PercentOff DECIMAL(5,2),
+ @AmountOff BIGINT,
+ @Currency VARCHAR(10),
+ @Duration VARCHAR(20),
+ @DurationInMonths INT,
+ @Name NVARCHAR(100),
+ @StartDate DATETIME2(7),
+ @EndDate DATETIME2(7),
+ @AudienceType INT,
+ @CreationDate DATETIME2(7),
+ @RevisionDate DATETIME2(7)
+AS
+BEGIN
+ SET NOCOUNT ON
+
+ INSERT INTO [dbo].[SubscriptionDiscount]
+ (
+ [Id],
+ [StripeCouponId],
+ [StripeProductIds],
+ [PercentOff],
+ [AmountOff],
+ [Currency],
+ [Duration],
+ [DurationInMonths],
+ [Name],
+ [StartDate],
+ [EndDate],
+ [AudienceType],
+ [CreationDate],
+ [RevisionDate]
+ )
+ VALUES
+ (
+ @Id,
+ @StripeCouponId,
+ @StripeProductIds,
+ @PercentOff,
+ @AmountOff,
+ @Currency,
+ @Duration,
+ @DurationInMonths,
+ @Name,
+ @StartDate,
+ @EndDate,
+ @AudienceType,
+ @CreationDate,
+ @RevisionDate
+ )
+END
diff --git a/src/Sql/dbo/Stored Procedures/SubscriptionDiscount_DeleteById.sql b/src/Sql/dbo/Stored Procedures/SubscriptionDiscount_DeleteById.sql
new file mode 100644
index 0000000000..8d44a4b098
--- /dev/null
+++ b/src/Sql/dbo/Stored Procedures/SubscriptionDiscount_DeleteById.sql
@@ -0,0 +1,12 @@
+CREATE PROCEDURE [dbo].[SubscriptionDiscount_DeleteById]
+ @Id UNIQUEIDENTIFIER
+AS
+BEGIN
+ SET NOCOUNT ON
+
+ DELETE
+ FROM
+ [dbo].[SubscriptionDiscount]
+ WHERE
+ [Id] = @Id
+END
diff --git a/src/Sql/dbo/Stored Procedures/SubscriptionDiscount_ReadActive.sql b/src/Sql/dbo/Stored Procedures/SubscriptionDiscount_ReadActive.sql
new file mode 100644
index 0000000000..6247492789
--- /dev/null
+++ b/src/Sql/dbo/Stored Procedures/SubscriptionDiscount_ReadActive.sql
@@ -0,0 +1,13 @@
+CREATE PROCEDURE [dbo].[SubscriptionDiscount_ReadActive]
+AS
+BEGIN
+ SET NOCOUNT ON
+
+ SELECT
+ *
+ FROM
+ [dbo].[SubscriptionDiscountView]
+ WHERE
+ [StartDate] <= GETUTCDATE()
+ AND [EndDate] >= GETUTCDATE()
+END
diff --git a/src/Sql/dbo/Stored Procedures/SubscriptionDiscount_ReadById.sql b/src/Sql/dbo/Stored Procedures/SubscriptionDiscount_ReadById.sql
new file mode 100644
index 0000000000..88943def64
--- /dev/null
+++ b/src/Sql/dbo/Stored Procedures/SubscriptionDiscount_ReadById.sql
@@ -0,0 +1,13 @@
+CREATE PROCEDURE [dbo].[SubscriptionDiscount_ReadById]
+ @Id UNIQUEIDENTIFIER
+AS
+BEGIN
+ SET NOCOUNT ON
+
+ SELECT
+ *
+ FROM
+ [dbo].[SubscriptionDiscountView]
+ WHERE
+ [Id] = @Id
+END
diff --git a/src/Sql/dbo/Stored Procedures/SubscriptionDiscount_ReadByStripeCouponId.sql b/src/Sql/dbo/Stored Procedures/SubscriptionDiscount_ReadByStripeCouponId.sql
new file mode 100644
index 0000000000..e935d614ed
--- /dev/null
+++ b/src/Sql/dbo/Stored Procedures/SubscriptionDiscount_ReadByStripeCouponId.sql
@@ -0,0 +1,13 @@
+CREATE PROCEDURE [dbo].[SubscriptionDiscount_ReadByStripeCouponId]
+ @StripeCouponId VARCHAR(50)
+AS
+BEGIN
+ SET NOCOUNT ON
+
+ SELECT
+ *
+ FROM
+ [dbo].[SubscriptionDiscountView]
+ WHERE
+ [StripeCouponId] = @StripeCouponId
+END
diff --git a/src/Sql/dbo/Stored Procedures/SubscriptionDiscount_Update.sql b/src/Sql/dbo/Stored Procedures/SubscriptionDiscount_Update.sql
new file mode 100644
index 0000000000..906f879a59
--- /dev/null
+++ b/src/Sql/dbo/Stored Procedures/SubscriptionDiscount_Update.sql
@@ -0,0 +1,38 @@
+CREATE PROCEDURE [dbo].[SubscriptionDiscount_Update]
+ @Id UNIQUEIDENTIFIER OUTPUT,
+ @StripeCouponId VARCHAR(50),
+ @StripeProductIds NVARCHAR(MAX),
+ @PercentOff DECIMAL(5,2),
+ @AmountOff BIGINT,
+ @Currency VARCHAR(10),
+ @Duration VARCHAR(20),
+ @DurationInMonths INT,
+ @Name NVARCHAR(100),
+ @StartDate DATETIME2(7),
+ @EndDate DATETIME2(7),
+ @AudienceType INT,
+ @CreationDate DATETIME2(7),
+ @RevisionDate DATETIME2(7)
+AS
+BEGIN
+ SET NOCOUNT ON
+
+ UPDATE
+ [dbo].[SubscriptionDiscount]
+ SET
+ [StripeCouponId] = @StripeCouponId,
+ [StripeProductIds] = @StripeProductIds,
+ [PercentOff] = @PercentOff,
+ [AmountOff] = @AmountOff,
+ [Currency] = @Currency,
+ [Duration] = @Duration,
+ [DurationInMonths] = @DurationInMonths,
+ [Name] = @Name,
+ [StartDate] = @StartDate,
+ [EndDate] = @EndDate,
+ [AudienceType] = @AudienceType,
+ [CreationDate] = @CreationDate,
+ [RevisionDate] = @RevisionDate
+ WHERE
+ [Id] = @Id
+END
diff --git a/src/Sql/dbo/Tables/SubscriptionDiscount.sql b/src/Sql/dbo/Tables/SubscriptionDiscount.sql
new file mode 100644
index 0000000000..36ca7c24eb
--- /dev/null
+++ b/src/Sql/dbo/Tables/SubscriptionDiscount.sql
@@ -0,0 +1,22 @@
+CREATE TABLE [dbo].[SubscriptionDiscount] (
+ [Id] UNIQUEIDENTIFIER NOT NULL,
+ [StripeCouponId] VARCHAR (50) NOT NULL,
+ [StripeProductIds] NVARCHAR (MAX) NULL,
+ [PercentOff] DECIMAL (5, 2) NULL,
+ [AmountOff] BIGINT NULL,
+ [Currency] VARCHAR (10) NULL,
+ [Duration] VARCHAR (20) NOT NULL,
+ [DurationInMonths] INT NULL,
+ [Name] NVARCHAR (100) NULL,
+ [StartDate] DATETIME2 (7) NOT NULL,
+ [EndDate] DATETIME2 (7) NOT NULL,
+ [AudienceType] INT NOT NULL CONSTRAINT [DF_SubscriptionDiscount_AudienceType] DEFAULT (0),
+ [CreationDate] DATETIME2 (7) NOT NULL,
+ [RevisionDate] DATETIME2 (7) NOT NULL,
+ CONSTRAINT [PK_SubscriptionDiscount] PRIMARY KEY CLUSTERED ([Id] ASC),
+ CONSTRAINT [IX_SubscriptionDiscount_StripeCouponId] UNIQUE NONCLUSTERED ([StripeCouponId] ASC)
+);
+
+GO
+CREATE NONCLUSTERED INDEX [IX_SubscriptionDiscount_DateRange]
+ ON [dbo].[SubscriptionDiscount]([StartDate] ASC, [EndDate] ASC);
diff --git a/src/Sql/dbo/Views/SubscriptionDiscountView.sql b/src/Sql/dbo/Views/SubscriptionDiscountView.sql
new file mode 100644
index 0000000000..71687d4ae8
--- /dev/null
+++ b/src/Sql/dbo/Views/SubscriptionDiscountView.sql
@@ -0,0 +1,5 @@
+CREATE VIEW [dbo].[SubscriptionDiscountView]
+AS
+SELECT *
+FROM
+ [dbo].[SubscriptionDiscount]
diff --git a/test/Core.Test/Billing/Subscriptions/Entities/SubscriptionDiscountTests.cs b/test/Core.Test/Billing/Subscriptions/Entities/SubscriptionDiscountTests.cs
new file mode 100644
index 0000000000..8da3b5ea1d
--- /dev/null
+++ b/test/Core.Test/Billing/Subscriptions/Entities/SubscriptionDiscountTests.cs
@@ -0,0 +1,109 @@
+using System.Text.Json;
+using Bit.Core.Billing.Enums;
+using Bit.Core.Billing.Subscriptions.Entities;
+using Xunit;
+
+namespace Bit.Core.Test.Billing.Subscriptions.Entities;
+
+public class SubscriptionDiscountTests
+{
+ [Fact]
+ public void StripeProductIds_CanSerializeToJson()
+ {
+ // Arrange
+ var discount = new SubscriptionDiscount
+ {
+ StripeCouponId = "test-coupon",
+ StripeProductIds = new List { "prod_123", "prod_456" },
+ Duration = "once",
+ StartDate = DateTime.UtcNow,
+ EndDate = DateTime.UtcNow.AddDays(30),
+ AudienceType = DiscountAudienceType.UserHasNoPreviousSubscriptions
+ };
+
+ // Act
+ var json = JsonSerializer.Serialize(discount.StripeProductIds);
+
+ // Assert
+ Assert.Equal("[\"prod_123\",\"prod_456\"]", json);
+ }
+
+ [Fact]
+ public void StripeProductIds_CanDeserializeFromJson()
+ {
+ // Arrange
+ var json = "[\"prod_123\",\"prod_456\"]";
+
+ // Act
+ var result = JsonSerializer.Deserialize>(json);
+
+ // Assert
+ Assert.NotNull(result);
+ Assert.Equal(2, result.Count);
+ Assert.Contains("prod_123", result);
+ Assert.Contains("prod_456", result);
+ }
+
+ [Fact]
+ public void StripeProductIds_HandlesNull()
+ {
+ // Arrange
+ var discount = new SubscriptionDiscount
+ {
+ StripeCouponId = "test-coupon",
+ StripeProductIds = null,
+ Duration = "once",
+ StartDate = DateTime.UtcNow,
+ EndDate = DateTime.UtcNow.AddDays(30),
+ AudienceType = DiscountAudienceType.UserHasNoPreviousSubscriptions
+ };
+
+ // Act
+ var json = JsonSerializer.Serialize(discount.StripeProductIds);
+
+ // Assert
+ Assert.Equal("null", json);
+ }
+
+ [Fact]
+ public void StripeProductIds_HandlesEmptyCollection()
+ {
+ // Arrange
+ var discount = new SubscriptionDiscount
+ {
+ StripeCouponId = "test-coupon",
+ StripeProductIds = new List(),
+ Duration = "once",
+ StartDate = DateTime.UtcNow,
+ EndDate = DateTime.UtcNow.AddDays(30),
+ AudienceType = DiscountAudienceType.UserHasNoPreviousSubscriptions
+ };
+
+ // Act
+ var json = JsonSerializer.Serialize(discount.StripeProductIds);
+
+ // Assert
+ Assert.Equal("[]", json);
+ }
+
+ [Fact]
+ public void Validate_RejectsEndDateBeforeStartDate()
+ {
+ // Arrange
+ var discount = new SubscriptionDiscount
+ {
+ StripeCouponId = "test-coupon",
+ Duration = "once",
+ StartDate = DateTime.UtcNow.AddDays(30),
+ EndDate = DateTime.UtcNow, // EndDate before StartDate
+ AudienceType = DiscountAudienceType.UserHasNoPreviousSubscriptions
+ };
+
+ // Act
+ var validationResults = discount.Validate(new System.ComponentModel.DataAnnotations.ValidationContext(discount)).ToList();
+
+ // Assert
+ Assert.Single(validationResults);
+ Assert.Contains("EndDate", validationResults[0].MemberNames);
+ }
+}
diff --git a/test/Infrastructure.IntegrationTest/Billing/Repositories/SubscriptionDiscountRepositoryTests.cs b/test/Infrastructure.IntegrationTest/Billing/Repositories/SubscriptionDiscountRepositoryTests.cs
new file mode 100644
index 0000000000..84b357ee62
--- /dev/null
+++ b/test/Infrastructure.IntegrationTest/Billing/Repositories/SubscriptionDiscountRepositoryTests.cs
@@ -0,0 +1,187 @@
+using Bit.Core.Billing.Enums;
+using Bit.Core.Billing.Subscriptions.Entities;
+using Bit.Core.Billing.Subscriptions.Repositories;
+using Xunit;
+
+namespace Bit.Infrastructure.IntegrationTest.Billing.Repositories;
+
+public class SubscriptionDiscountRepositoryTests
+{
+ private static SubscriptionDiscount CreateTestDiscount(
+ string? stripeCouponId = null,
+ ICollection? stripeProductIds = null,
+ decimal? percentOff = null,
+ long? amountOff = null,
+ string? currency = null,
+ string duration = "once",
+ int? durationInMonths = null,
+ string? name = null,
+ DateTime? startDate = null,
+ DateTime? endDate = null,
+ DiscountAudienceType audienceType = DiscountAudienceType.UserHasNoPreviousSubscriptions,
+ DateTime? creationDate = null,
+ DateTime? revisionDate = null)
+ {
+ var now = DateTime.UtcNow;
+ return new SubscriptionDiscount
+ {
+ StripeCouponId = stripeCouponId ?? $"test-{Guid.NewGuid()}",
+ StripeProductIds = stripeProductIds,
+ PercentOff = percentOff,
+ AmountOff = amountOff,
+ Currency = currency,
+ Duration = duration,
+ DurationInMonths = durationInMonths,
+ Name = name,
+ StartDate = startDate ?? now,
+ EndDate = endDate ?? now.AddDays(30),
+ AudienceType = audienceType,
+ CreationDate = creationDate ?? now,
+ RevisionDate = revisionDate ?? now
+ };
+ }
+
+ [Theory, DatabaseData]
+ public async Task GetActiveDiscountsAsync_ReturnsDiscountsWithinDateRange(
+ ISubscriptionDiscountRepository subscriptionDiscountRepository)
+ {
+ // Create a discount that is currently active
+ var activeDiscount = await subscriptionDiscountRepository.CreateAsync(
+ CreateTestDiscount(
+ stripeCouponId: $"test-active-{Guid.NewGuid()}",
+ percentOff: 25.00m,
+ name: "Active Discount",
+ startDate: DateTime.UtcNow.AddDays(-1),
+ endDate: DateTime.UtcNow.AddDays(30)));
+
+ // Create a discount that has expired
+ var expiredDiscount = await subscriptionDiscountRepository.CreateAsync(
+ CreateTestDiscount(
+ stripeCouponId: $"test-expired-{Guid.NewGuid()}",
+ percentOff: 50.00m,
+ name: "Expired Discount",
+ startDate: DateTime.UtcNow.AddDays(-60),
+ endDate: DateTime.UtcNow.AddDays(-30)));
+
+ // Create a discount that starts in the future
+ var futureDiscount = await subscriptionDiscountRepository.CreateAsync(
+ CreateTestDiscount(
+ stripeCouponId: $"test-future-{Guid.NewGuid()}",
+ percentOff: 15.00m,
+ name: "Future Discount",
+ startDate: DateTime.UtcNow.AddDays(30),
+ endDate: DateTime.UtcNow.AddDays(60)));
+
+ // Act
+ var activeDiscounts = await subscriptionDiscountRepository.GetActiveDiscountsAsync();
+
+ // Assert
+ Assert.Contains(activeDiscounts, d => d.Id == activeDiscount.Id);
+ Assert.DoesNotContain(activeDiscounts, d => d.Id == expiredDiscount.Id);
+ Assert.DoesNotContain(activeDiscounts, d => d.Id == futureDiscount.Id);
+ }
+
+ [Theory, DatabaseData]
+ public async Task GetByStripeCouponIdAsync_ReturnsCorrectDiscount(
+ ISubscriptionDiscountRepository subscriptionDiscountRepository)
+ {
+ // Arrange
+ var couponId = $"test-coupon-{Guid.NewGuid()}";
+ var discount = await subscriptionDiscountRepository.CreateAsync(
+ CreateTestDiscount(
+ stripeCouponId: couponId,
+ stripeProductIds: new List { "prod_123", "prod_456" },
+ percentOff: 20.00m,
+ duration: "repeating",
+ durationInMonths: 3,
+ name: "Test Discount",
+ endDate: DateTime.UtcNow.AddDays(90)));
+
+ // Act
+ var result = await subscriptionDiscountRepository.GetByStripeCouponIdAsync(couponId);
+
+ // Assert
+ Assert.NotNull(result);
+ Assert.Equal(discount.Id, result.Id);
+ Assert.Equal(couponId, result.StripeCouponId);
+ Assert.Equal(20.00m, result.PercentOff);
+ Assert.Equal(3, result.DurationInMonths);
+ }
+
+ [Theory, DatabaseData]
+ public async Task GetByStripeCouponIdAsync_ReturnsNull_WhenCouponDoesNotExist(
+ ISubscriptionDiscountRepository subscriptionDiscountRepository)
+ {
+ // Act
+ var result = await subscriptionDiscountRepository.GetByStripeCouponIdAsync("non-existent-coupon");
+
+ // Assert
+ Assert.Null(result);
+ }
+
+ [Theory, DatabaseData]
+ public async Task CreateAsync_CreatesDiscountSuccessfully(
+ ISubscriptionDiscountRepository subscriptionDiscountRepository)
+ {
+ // Arrange
+ var discount = CreateTestDiscount(
+ stripeCouponId: $"test-create-{Guid.NewGuid()}",
+ stripeProductIds: new List { "prod_789" },
+ amountOff: 500,
+ currency: "usd",
+ name: "Fixed Amount Discount");
+
+ // Act
+ var createdDiscount = await subscriptionDiscountRepository.CreateAsync(discount);
+
+ // Assert
+ Assert.NotNull(createdDiscount);
+ Assert.NotEqual(Guid.Empty, createdDiscount.Id);
+ Assert.Equal(discount.StripeCouponId, createdDiscount.StripeCouponId);
+ Assert.Equal(500, createdDiscount.AmountOff);
+ Assert.Equal("usd", createdDiscount.Currency);
+ }
+
+ [Theory, DatabaseData]
+ public async Task ReplaceAsync_UpdatesDiscountSuccessfully(
+ ISubscriptionDiscountRepository subscriptionDiscountRepository)
+ {
+ // Arrange
+ var discount = await subscriptionDiscountRepository.CreateAsync(
+ CreateTestDiscount(
+ stripeCouponId: $"test-update-{Guid.NewGuid()}",
+ percentOff: 10.00m,
+ name: "Original Name"));
+
+ // Act
+ discount.Name = "Updated Name";
+ discount.PercentOff = 15.00m;
+ discount.RevisionDate = DateTime.UtcNow;
+ await subscriptionDiscountRepository.ReplaceAsync(discount);
+
+ // Assert
+ var updatedDiscount = await subscriptionDiscountRepository.GetByIdAsync(discount.Id);
+ Assert.NotNull(updatedDiscount);
+ Assert.Equal("Updated Name", updatedDiscount.Name);
+ Assert.Equal(15.00m, updatedDiscount.PercentOff);
+ }
+
+ [Theory, DatabaseData]
+ public async Task DeleteAsync_RemovesDiscountSuccessfully(
+ ISubscriptionDiscountRepository subscriptionDiscountRepository)
+ {
+ // Arrange
+ var discount = await subscriptionDiscountRepository.CreateAsync(
+ CreateTestDiscount(
+ stripeCouponId: $"test-delete-{Guid.NewGuid()}",
+ percentOff: 25.00m,
+ name: "To Be Deleted"));
+
+ // Act
+ await subscriptionDiscountRepository.DeleteAsync(discount);
+
+ // Assert
+ var deletedDiscount = await subscriptionDiscountRepository.GetByIdAsync(discount.Id);
+ Assert.Null(deletedDiscount);
+ }
+}
diff --git a/util/Migrator/DbScripts/2026-01-27_00_AddSubscriptionDiscountTable.sql b/util/Migrator/DbScripts/2026-01-27_00_AddSubscriptionDiscountTable.sql
new file mode 100644
index 0000000000..7e70a5e915
--- /dev/null
+++ b/util/Migrator/DbScripts/2026-01-27_00_AddSubscriptionDiscountTable.sql
@@ -0,0 +1,200 @@
+-- Table
+IF OBJECT_ID('[dbo].[SubscriptionDiscount]') IS NULL
+BEGIN
+ CREATE TABLE [dbo].[SubscriptionDiscount] (
+ [Id] UNIQUEIDENTIFIER NOT NULL,
+ [StripeCouponId] VARCHAR(50) NOT NULL,
+ [StripeProductIds] NVARCHAR(MAX) NULL,
+ [PercentOff] DECIMAL(5,2) NULL,
+ [AmountOff] BIGINT NULL,
+ [Currency] VARCHAR(10) NULL,
+ [Duration] VARCHAR(20) NOT NULL,
+ [DurationInMonths] INT NULL,
+ [Name] NVARCHAR(100) NULL,
+ [StartDate] DATETIME2(7) NOT NULL,
+ [EndDate] DATETIME2(7) NOT NULL,
+ [AudienceType] INT NOT NULL CONSTRAINT [DF_SubscriptionDiscount_AudienceType] DEFAULT (0),
+ [CreationDate] DATETIME2(7) NOT NULL,
+ [RevisionDate] DATETIME2(7) NOT NULL,
+ CONSTRAINT [PK_SubscriptionDiscount] PRIMARY KEY CLUSTERED ([Id] ASC),
+ CONSTRAINT [IX_SubscriptionDiscount_StripeCouponId] UNIQUE ([StripeCouponId])
+ );
+END
+GO
+
+-- Index for date range queries
+IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'IX_SubscriptionDiscount_DateRange' AND object_id = OBJECT_ID('[dbo].[SubscriptionDiscount]'))
+BEGIN
+ CREATE INDEX [IX_SubscriptionDiscount_DateRange] ON [dbo].[SubscriptionDiscount]
+ ([StartDate], [EndDate]);
+END
+GO
+
+-- View
+CREATE OR ALTER VIEW [dbo].[SubscriptionDiscountView]
+AS
+SELECT *
+FROM
+ [dbo].[SubscriptionDiscount]
+GO
+
+-- Stored Procedures: Create
+CREATE OR ALTER PROCEDURE [dbo].[SubscriptionDiscount_Create]
+ @Id UNIQUEIDENTIFIER OUTPUT,
+ @StripeCouponId VARCHAR(50),
+ @StripeProductIds NVARCHAR(MAX),
+ @PercentOff DECIMAL(5,2),
+ @AmountOff BIGINT,
+ @Currency VARCHAR(10),
+ @Duration VARCHAR(20),
+ @DurationInMonths INT,
+ @Name NVARCHAR(100),
+ @StartDate DATETIME2(7),
+ @EndDate DATETIME2(7),
+ @AudienceType INT,
+ @CreationDate DATETIME2(7),
+ @RevisionDate DATETIME2(7)
+AS
+BEGIN
+ SET NOCOUNT ON
+
+ INSERT INTO [dbo].[SubscriptionDiscount]
+ (
+ [Id],
+ [StripeCouponId],
+ [StripeProductIds],
+ [PercentOff],
+ [AmountOff],
+ [Currency],
+ [Duration],
+ [DurationInMonths],
+ [Name],
+ [StartDate],
+ [EndDate],
+ [AudienceType],
+ [CreationDate],
+ [RevisionDate]
+ )
+ VALUES
+ (
+ @Id,
+ @StripeCouponId,
+ @StripeProductIds,
+ @PercentOff,
+ @AmountOff,
+ @Currency,
+ @Duration,
+ @DurationInMonths,
+ @Name,
+ @StartDate,
+ @EndDate,
+ @AudienceType,
+ @CreationDate,
+ @RevisionDate
+ )
+END
+GO
+
+-- Stored Procedures: DeleteById
+CREATE OR ALTER PROCEDURE [dbo].[SubscriptionDiscount_DeleteById]
+ @Id UNIQUEIDENTIFIER
+AS
+BEGIN
+ SET NOCOUNT ON
+
+ DELETE
+ FROM
+ [dbo].[SubscriptionDiscount]
+ WHERE
+ [Id] = @Id
+END
+GO
+
+-- Stored Procedures: ReadById
+CREATE OR ALTER PROCEDURE [dbo].[SubscriptionDiscount_ReadById]
+ @Id UNIQUEIDENTIFIER
+AS
+BEGIN
+ SET NOCOUNT ON
+
+ SELECT
+ *
+ FROM
+ [dbo].[SubscriptionDiscountView]
+ WHERE
+ [Id] = @Id
+END
+GO
+
+-- Stored Procedures: Update
+CREATE OR ALTER PROCEDURE [dbo].[SubscriptionDiscount_Update]
+ @Id UNIQUEIDENTIFIER OUTPUT,
+ @StripeCouponId VARCHAR(50),
+ @StripeProductIds NVARCHAR(MAX),
+ @PercentOff DECIMAL(5,2),
+ @AmountOff BIGINT,
+ @Currency VARCHAR(10),
+ @Duration VARCHAR(20),
+ @DurationInMonths INT,
+ @Name NVARCHAR(100),
+ @StartDate DATETIME2(7),
+ @EndDate DATETIME2(7),
+ @AudienceType INT,
+ @CreationDate DATETIME2(7),
+ @RevisionDate DATETIME2(7)
+AS
+BEGIN
+ SET NOCOUNT ON
+
+ UPDATE
+ [dbo].[SubscriptionDiscount]
+ SET
+ [StripeCouponId] = @StripeCouponId,
+ [StripeProductIds] = @StripeProductIds,
+ [PercentOff] = @PercentOff,
+ [AmountOff] = @AmountOff,
+ [Currency] = @Currency,
+ [Duration] = @Duration,
+ [DurationInMonths] = @DurationInMonths,
+ [Name] = @Name,
+ [StartDate] = @StartDate,
+ [EndDate] = @EndDate,
+ [AudienceType] = @AudienceType,
+ [CreationDate] = @CreationDate,
+ [RevisionDate] = @RevisionDate
+ WHERE
+ [Id] = @Id
+END
+GO
+
+-- Stored Procedures: ReadActive (returns discounts within date range)
+CREATE OR ALTER PROCEDURE [dbo].[SubscriptionDiscount_ReadActive]
+AS
+BEGIN
+ SET NOCOUNT ON
+
+ SELECT
+ *
+ FROM
+ [dbo].[SubscriptionDiscountView]
+ WHERE
+ [StartDate] <= GETUTCDATE()
+ AND [EndDate] >= GETUTCDATE()
+END
+GO
+
+-- Stored Procedures: ReadByStripeCouponId
+CREATE OR ALTER PROCEDURE [dbo].[SubscriptionDiscount_ReadByStripeCouponId]
+ @StripeCouponId VARCHAR(50)
+AS
+BEGIN
+ SET NOCOUNT ON
+
+ SELECT
+ *
+ FROM
+ [dbo].[SubscriptionDiscountView]
+ WHERE
+ [StripeCouponId] = @StripeCouponId
+END
+GO
diff --git a/util/MySqlMigrations/Migrations/20260203083629_AddSubscriptionDiscountTable.Designer.cs b/util/MySqlMigrations/Migrations/20260203083629_AddSubscriptionDiscountTable.Designer.cs
new file mode 100644
index 0000000000..c47baf2822
--- /dev/null
+++ b/util/MySqlMigrations/Migrations/20260203083629_AddSubscriptionDiscountTable.Designer.cs
@@ -0,0 +1,3570 @@
+//
+using System;
+using Bit.Infrastructure.EntityFramework.Repositories;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Metadata;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+
+#nullable disable
+
+namespace Bit.MySqlMigrations.Migrations
+{
+ [DbContext(typeof(DatabaseContext))]
+ [Migration("20260203083629_AddSubscriptionDiscountTable")]
+ partial class AddSubscriptionDiscountTable
+ {
+ ///
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder
+ .HasAnnotation("ProductVersion", "8.0.8")
+ .HasAnnotation("Relational:MaxIdentifierLength", 64);
+
+ MySqlModelBuilderExtensions.AutoIncrementColumns(modelBuilder);
+
+ modelBuilder.Entity("Bit.Core.Dirt.Reports.Models.Data.OrganizationMemberBaseDetail", b =>
+ {
+ b.Property("CipherId")
+ .HasColumnType("char(36)");
+
+ b.Property("CollectionId")
+ .HasColumnType("char(36)");
+
+ b.Property("CollectionName")
+ .HasColumnType("longtext");
+
+ b.Property("Email")
+ .HasColumnType("longtext");
+
+ b.Property("GroupId")
+ .HasColumnType("char(36)");
+
+ b.Property("GroupName")
+ .HasColumnType("longtext");
+
+ b.Property("HidePasswords")
+ .HasColumnType("tinyint(1)");
+
+ b.Property("Manage")
+ .HasColumnType("tinyint(1)");
+
+ b.Property("ReadOnly")
+ .HasColumnType("tinyint(1)");
+
+ b.Property("ResetPasswordKey")
+ .HasColumnType("longtext");
+
+ b.Property("TwoFactorProviders")
+ .HasColumnType("longtext");
+
+ b.Property("UserGuid")
+ .HasColumnType("char(36)");
+
+ b.Property("UserName")
+ .HasColumnType("longtext");
+
+ b.Property("UsesKeyConnector")
+ .HasColumnType("tinyint(1)");
+
+ b.ToTable("OrganizationMemberBaseDetails");
+ });
+
+ modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("char(36)");
+
+ b.Property("AllowAdminAccessToAllCollectionItems")
+ .HasColumnType("tinyint(1)")
+ .HasDefaultValue(true);
+
+ b.Property("BillingEmail")
+ .IsRequired()
+ .HasMaxLength(256)
+ .HasColumnType("varchar(256)");
+
+ b.Property("BusinessAddress1")
+ .HasMaxLength(50)
+ .HasColumnType("varchar(50)");
+
+ b.Property("BusinessAddress2")
+ .HasMaxLength(50)
+ .HasColumnType("varchar(50)");
+
+ b.Property("BusinessAddress3")
+ .HasMaxLength(50)
+ .HasColumnType("varchar(50)");
+
+ b.Property("BusinessCountry")
+ .HasMaxLength(2)
+ .HasColumnType("varchar(2)");
+
+ b.Property("BusinessName")
+ .HasMaxLength(50)
+ .HasColumnType("varchar(50)");
+
+ b.Property("BusinessTaxNumber")
+ .HasMaxLength(30)
+ .HasColumnType("varchar(30)");
+
+ b.Property("CreationDate")
+ .HasColumnType("datetime(6)");
+
+ b.Property("Enabled")
+ .HasColumnType("tinyint(1)");
+
+ b.Property("ExpirationDate")
+ .HasColumnType("datetime(6)");
+
+ b.Property("Gateway")
+ .HasColumnType("tinyint unsigned");
+
+ b.Property("GatewayCustomerId")
+ .HasMaxLength(50)
+ .HasColumnType("varchar(50)");
+
+ b.Property("GatewaySubscriptionId")
+ .HasMaxLength(50)
+ .HasColumnType("varchar(50)");
+
+ b.Property("Identifier")
+ .HasMaxLength(50)
+ .HasColumnType("varchar(50)");
+
+ b.Property("LicenseKey")
+ .HasMaxLength(100)
+ .HasColumnType("varchar(100)");
+
+ b.Property("LimitCollectionCreation")
+ .HasColumnType("tinyint(1)");
+
+ b.Property("LimitCollectionDeletion")
+ .HasColumnType("tinyint(1)");
+
+ b.Property("LimitItemDeletion")
+ .HasColumnType("tinyint(1)");
+
+ b.Property("MaxAutoscaleSeats")
+ .HasColumnType("int");
+
+ b.Property("MaxAutoscaleSmSeats")
+ .HasColumnType("int");
+
+ b.Property("MaxAutoscaleSmServiceAccounts")
+ .HasColumnType("int");
+
+ b.Property("MaxCollections")
+ .HasColumnType("smallint");
+
+ b.Property("MaxStorageGb")
+ .HasColumnType("smallint");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(50)
+ .HasColumnType("varchar(50)");
+
+ b.Property("OwnersNotifiedOfAutoscaling")
+ .HasColumnType("datetime(6)");
+
+ b.Property("Plan")
+ .IsRequired()
+ .HasMaxLength(50)
+ .HasColumnType("varchar(50)");
+
+ b.Property("PlanType")
+ .HasColumnType("tinyint unsigned");
+
+ b.Property("PrivateKey")
+ .HasColumnType("longtext");
+
+ b.Property("PublicKey")
+ .HasColumnType("longtext");
+
+ b.Property("ReferenceData")
+ .HasColumnType("longtext");
+
+ b.Property("RevisionDate")
+ .HasColumnType("datetime(6)");
+
+ b.Property("Seats")
+ .HasColumnType("int");
+
+ b.Property("SelfHost")
+ .HasColumnType("tinyint(1)");
+
+ b.Property("SmSeats")
+ .HasColumnType("int");
+
+ b.Property("SmServiceAccounts")
+ .HasColumnType("int");
+
+ b.Property("Status")
+ .HasColumnType("tinyint unsigned");
+
+ b.Property("Storage")
+ .HasColumnType("bigint");
+
+ b.Property("SyncSeats")
+ .HasColumnType("tinyint(1)");
+
+ b.Property("TwoFactorProviders")
+ .HasColumnType("longtext");
+
+ b.Property("Use2fa")
+ .HasColumnType("tinyint(1)");
+
+ b.Property("UseAdminSponsoredFamilies")
+ .HasColumnType("tinyint(1)");
+
+ b.Property("UseApi")
+ .HasColumnType("tinyint(1)");
+
+ b.Property("UseAutomaticUserConfirmation")
+ .HasColumnType("tinyint(1)");
+
+ b.Property("UseCustomPermissions")
+ .HasColumnType("tinyint(1)");
+
+ b.Property("UseDirectory")
+ .HasColumnType("tinyint(1)");
+
+ b.Property("UseDisableSmAdsForUsers")
+ .HasColumnType("tinyint(1)");
+
+ b.Property("UseEvents")
+ .HasColumnType("tinyint(1)");
+
+ b.Property("UseGroups")
+ .HasColumnType("tinyint(1)");
+
+ b.Property("UseKeyConnector")
+ .HasColumnType("tinyint(1)");
+
+ b.Property("UseOrganizationDomains")
+ .HasColumnType("tinyint(1)");
+
+ b.Property("UsePasswordManager")
+ .HasColumnType("tinyint(1)");
+
+ b.Property("UsePhishingBlocker")
+ .HasColumnType("tinyint(1)");
+
+ b.Property("UsePolicies")
+ .HasColumnType("tinyint(1)");
+
+ b.Property("UseResetPassword")
+ .HasColumnType("tinyint(1)");
+
+ b.Property("UseRiskInsights")
+ .HasColumnType("tinyint(1)");
+
+ b.Property("UseScim")
+ .HasColumnType("tinyint(1)");
+
+ b.Property("UseSecretsManager")
+ .HasColumnType("tinyint(1)");
+
+ b.Property("UseSso")
+ .HasColumnType("tinyint(1)");
+
+ b.Property("UseTotp")
+ .HasColumnType("tinyint(1)");
+
+ b.Property("UsersGetPremium")
+ .HasColumnType("tinyint(1)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("Id", "Enabled")
+ .HasAnnotation("Npgsql:IndexInclude", new[] { "UseTotp", "UsersGetPremium" });
+
+ b.ToTable("Organization", (string)null);
+ });
+
+ modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Policy", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("char(36)");
+
+ b.Property("CreationDate")
+ .HasColumnType("datetime(6)");
+
+ b.Property("Data")
+ .HasColumnType("longtext");
+
+ b.Property("Enabled")
+ .HasColumnType("tinyint(1)");
+
+ b.Property("OrganizationId")
+ .HasColumnType("char(36)");
+
+ b.Property("RevisionDate")
+ .HasColumnType("datetime(6)");
+
+ b.Property("Type")
+ .HasColumnType("tinyint unsigned");
+
+ b.HasKey("Id");
+
+ b.HasIndex("OrganizationId")
+ .HasAnnotation("SqlServer:Clustered", false);
+
+ b.HasIndex("OrganizationId", "Type")
+ .IsUnique()
+ .HasAnnotation("SqlServer:Clustered", false);
+
+ b.ToTable("Policy", (string)null);
+ });
+
+ modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.Provider", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("char(36)");
+
+ b.Property("BillingEmail")
+ .HasColumnType("longtext");
+
+ b.Property("BillingPhone")
+ .HasColumnType("longtext");
+
+ b.Property("BusinessAddress1")
+ .HasColumnType("longtext");
+
+ b.Property("BusinessAddress2")
+ .HasColumnType("longtext");
+
+ b.Property("BusinessAddress3")
+ .HasColumnType("longtext");
+
+ b.Property("BusinessCountry")
+ .HasColumnType("longtext");
+
+ b.Property("BusinessName")
+ .HasColumnType("longtext");
+
+ b.Property("BusinessTaxNumber")
+ .HasColumnType("longtext");
+
+ b.Property("CreationDate")
+ .HasColumnType("datetime(6)");
+
+ b.Property("DiscountId")
+ .HasColumnType("longtext");
+
+ b.Property("Enabled")
+ .HasColumnType("tinyint(1)");
+
+ b.Property("Gateway")
+ .HasColumnType("tinyint unsigned");
+
+ b.Property("GatewayCustomerId")
+ .HasColumnType("longtext");
+
+ b.Property("GatewaySubscriptionId")
+ .HasColumnType("longtext");
+
+ b.Property("Name")
+ .HasColumnType("longtext");
+
+ b.Property("RevisionDate")
+ .HasColumnType("datetime(6)");
+
+ b.Property("Status")
+ .HasColumnType("tinyint unsigned");
+
+ b.Property("Type")
+ .HasColumnType("tinyint unsigned");
+
+ b.Property("UseEvents")
+ .HasColumnType("tinyint(1)");
+
+ b.HasKey("Id");
+
+ b.ToTable("Provider", (string)null);
+ });
+
+ modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.ProviderOrganization", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("char(36)");
+
+ b.Property("CreationDate")
+ .HasColumnType("datetime(6)");
+
+ b.Property("Key")
+ .HasColumnType("longtext");
+
+ b.Property("OrganizationId")
+ .HasColumnType("char(36)");
+
+ b.Property("ProviderId")
+ .HasColumnType("char(36)");
+
+ b.Property("RevisionDate")
+ .HasColumnType("datetime(6)");
+
+ b.Property("Settings")
+ .HasColumnType("longtext");
+
+ b.HasKey("Id");
+
+ b.HasIndex("OrganizationId");
+
+ b.HasIndex("ProviderId");
+
+ b.ToTable("ProviderOrganization", (string)null);
+ });
+
+ modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.ProviderUser", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("char(36)");
+
+ b.Property("CreationDate")
+ .HasColumnType("datetime(6)");
+
+ b.Property("Email")
+ .HasColumnType("longtext");
+
+ b.Property("Key")
+ .HasColumnType("longtext");
+
+ b.Property("Permissions")
+ .HasColumnType("longtext");
+
+ b.Property("ProviderId")
+ .HasColumnType("char(36)");
+
+ b.Property("RevisionDate")
+ .HasColumnType("datetime(6)");
+
+ b.Property("Status")
+ .HasColumnType("tinyint unsigned");
+
+ b.Property("Type")
+ .HasColumnType("tinyint unsigned");
+
+ b.Property("UserId")
+ .HasColumnType("char(36)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("ProviderId");
+
+ b.HasIndex("UserId");
+
+ b.ToTable("ProviderUser", (string)null);
+ });
+
+ modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.AuthRequest", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("char(36)");
+
+ b.Property("AccessCode")
+ .HasMaxLength(25)
+ .HasColumnType("varchar(25)");
+
+ b.Property("Approved")
+ .HasColumnType("tinyint(1)");
+
+ b.Property("AuthenticationDate")
+ .HasColumnType("datetime(6)");
+
+ b.Property("CreationDate")
+ .HasColumnType("datetime(6)");
+
+ b.Property("Key")
+ .HasColumnType("longtext");
+
+ b.Property("MasterPasswordHash")
+ .HasColumnType("longtext");
+
+ b.Property("OrganizationId")
+ .HasColumnType("char(36)");
+
+ b.Property("PublicKey")
+ .HasColumnType("longtext");
+
+ b.Property("RequestCountryName")
+ .HasMaxLength(200)
+ .HasColumnType("varchar(200)");
+
+ b.Property("RequestDeviceIdentifier")
+ .HasMaxLength(50)
+ .HasColumnType("varchar(50)");
+
+ b.Property("RequestDeviceType")
+ .HasColumnType("tinyint unsigned");
+
+ b.Property("RequestIpAddress")
+ .HasMaxLength(50)
+ .HasColumnType("varchar(50)");
+
+ b.Property("ResponseDate")
+ .HasColumnType("datetime(6)");
+
+ b.Property("ResponseDeviceId")
+ .HasColumnType("char(36)");
+
+ b.Property("Type")
+ .HasColumnType("tinyint unsigned");
+
+ b.Property("UserId")
+ .HasColumnType("char(36)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("OrganizationId");
+
+ b.HasIndex("ResponseDeviceId");
+
+ b.HasIndex("UserId");
+
+ b.ToTable("AuthRequest", (string)null);
+ });
+
+ modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.EmergencyAccess", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("char(36)");
+
+ b.Property("CreationDate")
+ .HasColumnType("datetime(6)");
+
+ b.Property("Email")
+ .HasMaxLength(256)
+ .HasColumnType("varchar(256)");
+
+ b.Property("GranteeId")
+ .HasColumnType("char(36)");
+
+ b.Property("GrantorId")
+ .HasColumnType("char(36)");
+
+ b.Property("KeyEncrypted")
+ .HasColumnType("longtext");
+
+ b.Property("LastNotificationDate")
+ .HasColumnType("datetime(6)");
+
+ b.Property("RecoveryInitiatedDate")
+ .HasColumnType("datetime(6)");
+
+ b.Property("RevisionDate")
+ .HasColumnType("datetime(6)");
+
+ b.Property("Status")
+ .HasColumnType("tinyint unsigned");
+
+ b.Property("Type")
+ .HasColumnType("tinyint unsigned");
+
+ b.Property("WaitTimeDays")
+ .HasColumnType("smallint");
+
+ b.HasKey("Id");
+
+ b.HasIndex("GranteeId");
+
+ b.HasIndex("GrantorId");
+
+ b.ToTable("EmergencyAccess", (string)null);
+ });
+
+ modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.Grant", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("int");
+
+ MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id"));
+
+ b.Property("ClientId")
+ .IsRequired()
+ .HasMaxLength(200)
+ .HasColumnType("varchar(200)");
+
+ b.Property("ConsumedDate")
+ .HasColumnType("datetime(6)");
+
+ b.Property("CreationDate")
+ .HasColumnType("datetime(6)");
+
+ b.Property("Data")
+ .IsRequired()
+ .HasColumnType("longtext");
+
+ b.Property("Description")
+ .HasMaxLength(200)
+ .HasColumnType("varchar(200)");
+
+ b.Property("ExpirationDate")
+ .HasColumnType("datetime(6)");
+
+ b.Property("Key")
+ .IsRequired()
+ .HasMaxLength(200)
+ .HasColumnType("varchar(200)");
+
+ b.Property("SessionId")
+ .HasMaxLength(100)
+ .HasColumnType("varchar(100)");
+
+ b.Property("SubjectId")
+ .HasMaxLength(200)
+ .HasColumnType("varchar(200)");
+
+ b.Property("Type")
+ .IsRequired()
+ .HasMaxLength(50)
+ .HasColumnType("varchar(50)");
+
+ b.HasKey("Id")
+ .HasName("PK_Grant")
+ .HasAnnotation("SqlServer:Clustered", true);
+
+ b.HasIndex("ExpirationDate")
+ .HasAnnotation("SqlServer:Clustered", false);
+
+ b.HasIndex("Key")
+ .IsUnique();
+
+ b.ToTable("Grant", (string)null);
+ });
+
+ modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.SsoConfig", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("bigint");
+
+ MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id"));
+
+ b.Property("CreationDate")
+ .HasColumnType("datetime(6)");
+
+ b.Property("Data")
+ .HasColumnType("longtext");
+
+ b.Property("Enabled")
+ .HasColumnType("tinyint(1)");
+
+ b.Property("OrganizationId")
+ .HasColumnType("char(36)");
+
+ b.Property("RevisionDate")
+ .HasColumnType("datetime(6)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("OrganizationId");
+
+ b.ToTable("SsoConfig", (string)null);
+ });
+
+ modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.SsoUser", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("bigint");
+
+ MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id"));
+
+ b.Property("CreationDate")
+ .HasColumnType("datetime(6)");
+
+ b.Property("ExternalId")
+ .HasMaxLength(300)
+ .HasColumnType("varchar(300)");
+
+ b.Property("OrganizationId")
+ .HasColumnType("char(36)");
+
+ b.Property("UserId")
+ .HasColumnType("char(36)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("OrganizationId")
+ .HasAnnotation("SqlServer:Clustered", false);
+
+ b.HasIndex("UserId");
+
+ b.HasIndex("OrganizationId", "ExternalId")
+ .IsUnique()
+ .HasAnnotation("Npgsql:IndexInclude", new[] { "UserId" })
+ .HasAnnotation("SqlServer:Clustered", false);
+
+ b.HasIndex("OrganizationId", "UserId")
+ .IsUnique()
+ .HasAnnotation("SqlServer:Clustered", false);
+
+ b.ToTable("SsoUser", (string)null);
+ });
+
+ modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.WebAuthnCredential", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("char(36)");
+
+ b.Property("AaGuid")
+ .HasColumnType("char(36)");
+
+ b.Property("Counter")
+ .HasColumnType("int");
+
+ b.Property("CreationDate")
+ .HasColumnType("datetime(6)");
+
+ b.Property("CredentialId")
+ .HasMaxLength(256)
+ .HasColumnType("varchar(256)");
+
+ b.Property("EncryptedPrivateKey")
+ .HasMaxLength(2000)
+ .HasColumnType("varchar(2000)");
+
+ b.Property("EncryptedPublicKey")
+ .HasMaxLength(2000)
+ .HasColumnType("varchar(2000)");
+
+ b.Property("EncryptedUserKey")
+ .HasMaxLength(2000)
+ .HasColumnType("varchar(2000)");
+
+ b.Property("Name")
+ .HasMaxLength(50)
+ .HasColumnType("varchar(50)");
+
+ b.Property("PublicKey")
+ .HasMaxLength(256)
+ .HasColumnType("varchar(256)");
+
+ b.Property("RevisionDate")
+ .HasColumnType("datetime(6)");
+
+ b.Property("SupportsPrf")
+ .HasColumnType("tinyint(1)");
+
+ b.Property("Type")
+ .HasMaxLength(20)
+ .HasColumnType("varchar(20)");
+
+ b.Property("UserId")
+ .HasColumnType("char(36)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("UserId");
+
+ b.ToTable("WebAuthnCredential", (string)null);
+ });
+
+ modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.ClientOrganizationMigrationRecord", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("char(36)");
+
+ b.Property("ExpirationDate")
+ .HasColumnType("datetime(6)");
+
+ b.Property("GatewayCustomerId")
+ .IsRequired()
+ .HasMaxLength(50)
+ .HasColumnType("varchar(50)");
+
+ b.Property("GatewaySubscriptionId")
+ .IsRequired()
+ .HasMaxLength(50)
+ .HasColumnType("varchar(50)");
+
+ b.Property("MaxAutoscaleSeats")
+ .HasColumnType("int");
+
+ b.Property("MaxStorageGb")
+ .HasColumnType("smallint");
+
+ b.Property("OrganizationId")
+ .HasColumnType("char(36)");
+
+ b.Property("PlanType")
+ .HasColumnType("tinyint unsigned");
+
+ b.Property("ProviderId")
+ .HasColumnType("char(36)");
+
+ b.Property("Seats")
+ .HasColumnType("int");
+
+ b.Property("Status")
+ .HasColumnType("tinyint unsigned");
+
+ b.HasKey("Id");
+
+ b.HasIndex("ProviderId", "OrganizationId")
+ .IsUnique();
+
+ b.ToTable("ClientOrganizationMigrationRecord", (string)null);
+ });
+
+ modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.OrganizationInstallation", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("char(36)");
+
+ b.Property("CreationDate")
+ .HasColumnType("datetime(6)");
+
+ b.Property("InstallationId")
+ .HasColumnType("char(36)");
+
+ b.Property("OrganizationId")
+ .HasColumnType("char(36)");
+
+ b.Property("RevisionDate")
+ .HasColumnType("datetime(6)");
+
+ b.HasKey("Id")
+ .HasAnnotation("SqlServer:Clustered", true);
+
+ b.HasIndex("InstallationId")
+ .HasAnnotation("SqlServer:Clustered", false);
+
+ b.HasIndex("OrganizationId")
+ .HasAnnotation("SqlServer:Clustered", false);
+
+ b.ToTable("OrganizationInstallation", (string)null);
+ });
+
+ modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.ProviderInvoiceItem", b =>
+ {
+ b.Property