feat(saas): implement marketplace and tenant onboarding

This commit is contained in:
2026-07-29 13:58:59 +08:00
parent 76606029e2
commit 6db200a2fc
145 changed files with 16862 additions and 94529 deletions

View File

@@ -299,35 +299,6 @@ internal sealed class CouponRedemptionConfiguration : IEntityTypeConfiguration<C
}
}
internal sealed class TenantSubscriptionConfiguration : IEntityTypeConfiguration<TenantSubscription>
{
public void Configure(EntityTypeBuilder<TenantSubscription> builder)
{
builder.ConfigureTenantEntity("tenant_subscriptions");
builder.ConfigureTimestamps();
builder.Property(entity => entity.PlanCode).HasMaxLength(100);
builder.Property(entity => entity.Status).HasSnakeCaseEnum();
builder.Property(entity => entity.BillingCycle).HasMaxLength(50);
builder.Property(entity => entity.Metadata).IsJson("{}");
builder.HasIndex(entity => new { entity.TenantId, entity.Status, entity.ExpiresAt });
}
}
internal sealed class TenantUsageRecordConfiguration : IEntityTypeConfiguration<TenantUsageRecord>
{
public void Configure(EntityTypeBuilder<TenantUsageRecord> builder)
{
builder.ConfigureEntity("tenant_usage_records");
builder.HasAlternateKey(entity => new { entity.TenantId, entity.Id });
builder.Property(entity => entity.MetricKey).HasMaxLength(100);
builder.Property(entity => entity.MetricValue).HasPrecision(18, 4);
builder.Property(entity => entity.Metadata).IsJson("{}");
builder.Property(entity => entity.CreatedAt).HasDefaultValueSql("now()");
builder.HasIndex(entity => new { entity.TenantId, entity.MetricKey, entity.PeriodStart, entity.PeriodEnd });
builder.HasOne<Tenant>().WithMany().HasForeignKey(entity => entity.TenantId).OnDelete(DeleteBehavior.Cascade);
}
}
internal sealed class CommerceRefundRequestConfiguration : IEntityTypeConfiguration<CommerceRefundRequest>
{
public void Configure(EntityTypeBuilder<CommerceRefundRequest> builder)

View File

@@ -4,6 +4,7 @@ using Tiku.Domain.Catalog;
using Tiku.Domain.Content;
using Tiku.Domain.Identity;
using Tiku.Domain.Operations;
using Tiku.Domain.Platform;
using Tiku.Domain.QuestionBanks;
using Tiku.Domain.Tenancy;
@@ -89,10 +90,14 @@ internal sealed class BackendPermissionConfiguration : IEntityTypeConfiguration<
builder.Property(entity => entity.Code).HasMaxLength(160);
builder.Property(entity => entity.Name).HasMaxLength(200);
builder.Property(entity => entity.Area).HasSnakeCaseEnum();
builder.Property(entity => entity.Module).HasMaxLength(100);
builder.Property(entity => entity.PermissionModuleCode).HasMaxLength(120);
builder.Property(entity => entity.Description).HasMaxLength(1000);
builder.HasIndex(entity => entity.Code).IsUnique();
builder.HasIndex(entity => new { entity.Area, entity.Module, entity.SortOrder });
builder.HasIndex(entity => new { entity.Area, entity.PermissionModuleCode, entity.SortOrder });
builder.HasOne<PermissionModule>().WithMany()
.HasPrincipalKey(entity => entity.Code)
.HasForeignKey(entity => entity.PermissionModuleCode)
.OnDelete(DeleteBehavior.Restrict);
}
}

View File

@@ -7,76 +7,6 @@ using Tiku.Domain.Tenancy;
namespace Tiku.Infrastructure.Persistence.Configurations;
internal sealed class PlatformSaasPlanConfiguration : IEntityTypeConfiguration<PlatformSaasPlan>
{
public void Configure(EntityTypeBuilder<PlatformSaasPlan> builder)
{
builder.ConfigureEntity("platform_saas_plans");
builder.ConfigureTimestamps();
builder.Property(entity => entity.Code).HasMaxLength(100);
builder.Property(entity => entity.Name).HasMaxLength(200);
builder.Property(entity => entity.BillingCycle).HasSnakeCaseEnum();
builder.Property(entity => entity.Currency).HasMaxLength(10);
builder.Property(entity => entity.IncludedQuotas).IsJson("{}");
builder.Property(entity => entity.OveragePrices).IsJson("{}");
builder.Property(entity => entity.FeatureFlags).IsJson("{}");
builder.Property(entity => entity.Status).HasSnakeCaseEnum();
builder.HasIndex(entity => entity.Code).IsUnique();
builder.HasIndex(entity => new { entity.Status, entity.SortOrder });
builder.ToTable(table => table.HasCheckConstraint("ck_platform_saas_plans_amount", "base_amount_cents >= 0"));
}
}
internal sealed class ProductModuleConfiguration : IEntityTypeConfiguration<ProductModule>
{
public void Configure(EntityTypeBuilder<ProductModule> builder)
{
builder.ConfigureEntity("product_modules");
builder.ConfigureTimestamps();
builder.Property(entity => entity.Code).HasMaxLength(100);
builder.Property(entity => entity.Name).HasMaxLength(200);
builder.Property(entity => entity.Description).HasMaxLength(1000);
builder.Property(entity => entity.Status).HasSnakeCaseEnum();
builder.HasIndex(entity => entity.Code).IsUnique();
}
}
internal sealed class PlanModuleEntitlementConfiguration : IEntityTypeConfiguration<PlanModuleEntitlement>
{
public void Configure(EntityTypeBuilder<PlanModuleEntitlement> builder)
{
builder.ConfigureEntity("plan_module_entitlements");
builder.Property(entity => entity.PlanCode).HasMaxLength(100);
builder.Property(entity => entity.ModuleCode).HasMaxLength(100);
builder.HasIndex(entity => new { entity.PlanCode, entity.ModuleCode }).IsUnique();
builder.HasOne<PlatformSaasPlan>().WithMany()
.HasPrincipalKey(entity => entity.Code)
.HasForeignKey(entity => entity.PlanCode)
.OnDelete(DeleteBehavior.Cascade);
builder.HasOne<ProductModule>().WithMany()
.HasPrincipalKey(entity => entity.Code)
.HasForeignKey(entity => entity.ModuleCode)
.OnDelete(DeleteBehavior.Cascade);
}
}
internal sealed class TenantModuleOverrideConfiguration : IEntityTypeConfiguration<TenantModuleOverride>
{
public void Configure(EntityTypeBuilder<TenantModuleOverride> builder)
{
builder.ConfigureTenantEntity("tenant_module_overrides");
builder.ConfigureTimestamps();
builder.Property(entity => entity.ModuleCode).HasMaxLength(100);
builder.Property(entity => entity.Mode).HasSnakeCaseEnum();
builder.Property(entity => entity.Reason).HasMaxLength(1000);
builder.HasIndex(entity => new { entity.TenantId, entity.ModuleCode }).IsUnique();
builder.HasOne<ProductModule>().WithMany()
.HasPrincipalKey(entity => entity.Code)
.HasForeignKey(entity => entity.ModuleCode)
.OnDelete(DeleteBehavior.Restrict);
}
}
internal sealed class TenantBillingProfileConfiguration : IEntityTypeConfiguration<TenantBillingProfile>
{
public void Configure(EntityTypeBuilder<TenantBillingProfile> builder)
@@ -98,84 +28,11 @@ internal sealed class TenantBillingProfileConfiguration : IEntityTypeConfigurati
}
}
internal sealed class TenantInvoiceConfiguration : IEntityTypeConfiguration<TenantInvoice>
internal sealed class PlatformBillingInvoiceReminderConfiguration : IEntityTypeConfiguration<PlatformBillingInvoiceReminder>
{
public void Configure(EntityTypeBuilder<TenantInvoice> builder)
public void Configure(EntityTypeBuilder<PlatformBillingInvoiceReminder> builder)
{
builder.ConfigureTenantEntity("tenant_invoices");
builder.ConfigureTimestamps();
builder.Property(entity => entity.InvoiceNo).HasMaxLength(100);
builder.Property(entity => entity.InvoiceType).HasSnakeCaseEnum();
builder.Property(entity => entity.Status).HasSnakeCaseEnum();
builder.Property(entity => entity.Currency).HasMaxLength(10);
builder.Property(entity => entity.Metadata).IsJson("{}");
builder.HasIndex(entity => new { entity.TenantId, entity.InvoiceNo }).IsUnique();
builder.HasIndex(entity => new { entity.TenantId, entity.Status, entity.DueDate });
builder.HasIndex(entity => new { entity.TenantId, entity.BillingPeriodStart, entity.BillingPeriodEnd })
.IsUnique()
.HasFilter("invoice_type = 'usage_overage' and status <> 'void' and metadata->>'source' = 'usage_overage_auto'");
builder.ToTable(table =>
{
table.HasCheckConstraint("ck_tenant_invoices_amounts", "subtotal_cents >= 0 and discount_cents >= 0 and tax_cents >= 0 and total_cents >= 0 and paid_cents >= 0 and balance_cents >= 0");
table.HasCheckConstraint("ck_tenant_invoices_period", "billing_period_end is null or billing_period_start is null or billing_period_end >= billing_period_start");
});
builder.HasOne<User>().WithMany().HasForeignKey(entity => entity.CreatedBy).OnDelete(DeleteBehavior.SetNull);
}
}
internal sealed class TenantInvoiceItemConfiguration : IEntityTypeConfiguration<TenantInvoiceItem>
{
public void Configure(EntityTypeBuilder<TenantInvoiceItem> builder)
{
builder.ConfigureEntity("tenant_invoice_items");
builder.HasAlternateKey(entity => new { entity.TenantId, entity.Id });
builder.Property(entity => entity.ItemType).HasMaxLength(100);
builder.Property(entity => entity.Description).HasMaxLength(500);
builder.Property(entity => entity.Quantity).HasPrecision(12, 2);
builder.Property(entity => entity.Metadata).IsJson("{}");
builder.Property(entity => entity.CreatedAt).HasDefaultValueSql("now()");
builder.HasIndex(entity => entity.InvoiceId);
builder.ToTable(table =>
{
table.HasCheckConstraint("ck_tenant_invoice_items_quantity", "quantity > 0");
table.HasCheckConstraint("ck_tenant_invoice_items_amounts", "unit_amount_cents >= 0 and amount_cents >= 0");
});
builder.HasOne<Tenant>().WithMany().HasForeignKey(entity => entity.TenantId).OnDelete(DeleteBehavior.Cascade);
builder.HasOne<TenantInvoice>().WithMany()
.HasForeignKey(entity => new { entity.TenantId, entity.InvoiceId })
.HasPrincipalKey(entity => new { entity.TenantId, entity.Id })
.OnDelete(DeleteBehavior.Cascade);
}
}
internal sealed class TenantInvoicePaymentConfiguration : IEntityTypeConfiguration<TenantInvoicePayment>
{
public void Configure(EntityTypeBuilder<TenantInvoicePayment> builder)
{
builder.ConfigureTenantEntity("tenant_invoice_payments");
builder.ConfigureTimestamps();
builder.Property(entity => entity.PaymentNo).HasMaxLength(100);
builder.Property(entity => entity.Provider).HasMaxLength(50);
builder.Property(entity => entity.Method).HasMaxLength(50);
builder.Property(entity => entity.Status).HasSnakeCaseEnum();
builder.Property(entity => entity.ProviderTradeNo).HasMaxLength(200);
builder.Property(entity => entity.RawPayload).IsJson("{}");
builder.HasIndex(entity => new { entity.TenantId, entity.PaymentNo }).IsUnique();
builder.HasIndex(entity => new { entity.InvoiceId, entity.Status });
builder.ToTable(table => table.HasCheckConstraint("ck_tenant_invoice_payments_amount", "amount_cents >= 0"));
builder.HasOne<TenantInvoice>().WithMany()
.HasForeignKey(entity => new { entity.TenantId, entity.InvoiceId })
.HasPrincipalKey(entity => new { entity.TenantId, entity.Id })
.OnDelete(DeleteBehavior.Cascade);
builder.HasOne<User>().WithMany().HasForeignKey(entity => entity.ReceivedBy).OnDelete(DeleteBehavior.SetNull);
}
}
internal sealed class TenantInvoiceReminderConfiguration : IEntityTypeConfiguration<TenantInvoiceReminder>
{
public void Configure(EntityTypeBuilder<TenantInvoiceReminder> builder)
{
builder.ConfigureTenantEntity("tenant_invoice_reminders");
builder.ConfigureTenantEntity("platform_billing_invoice_reminders");
builder.ConfigureTimestamps();
builder.Property(entity => entity.ReminderType).HasSnakeCaseEnum();
builder.Property(entity => entity.Channel).HasSnakeCaseEnum();
@@ -186,10 +43,10 @@ internal sealed class TenantInvoiceReminderConfiguration : IEntityTypeConfigurat
builder.HasIndex(entity => new { entity.InvoiceId, entity.ReminderDate });
builder.ToTable(table =>
{
table.HasCheckConstraint("ck_tenant_invoice_reminders_level", "reminder_level between 1 and 20");
table.HasCheckConstraint("ck_tenant_invoice_reminders_balance", "balance_cents_snapshot >= 0");
table.HasCheckConstraint("ck_platform_billing_invoice_reminders_level", "reminder_level between 1 and 20");
table.HasCheckConstraint("ck_platform_billing_invoice_reminders_balance", "balance_cents_snapshot >= 0");
});
builder.HasOne<TenantInvoice>().WithMany()
builder.HasOne<PlatformBillingInvoice>().WithMany()
.HasForeignKey(entity => new { entity.TenantId, entity.InvoiceId })
.HasPrincipalKey(entity => new { entity.TenantId, entity.Id })
.OnDelete(DeleteBehavior.Cascade);
@@ -243,11 +100,11 @@ internal sealed class PlatformAuditAlertConfiguration : IEntityTypeConfiguration
}
}
internal sealed class PlatformDunningNotificationChannelConfiguration : IEntityTypeConfiguration<PlatformDunningNotificationChannel>
internal sealed class PlatformBillingDunningNotificationChannelConfiguration : IEntityTypeConfiguration<PlatformBillingDunningNotificationChannel>
{
public void Configure(EntityTypeBuilder<PlatformDunningNotificationChannel> builder)
public void Configure(EntityTypeBuilder<PlatformBillingDunningNotificationChannel> builder)
{
builder.ConfigureEntity("platform_dunning_notification_channels");
builder.ConfigureEntity("platform_billing_dunning_notification_channels");
builder.ConfigureTimestamps();
builder.Property(entity => entity.ChannelCode).HasMaxLength(100);
builder.Property(entity => entity.Name).HasMaxLength(200);
@@ -262,17 +119,17 @@ internal sealed class PlatformDunningNotificationChannelConfiguration : IEntityT
builder.HasIndex(entity => new { entity.Enabled, entity.MinReminderLevel, entity.ChannelCode });
builder.ToTable(table =>
{
table.HasCheckConstraint("ck_platform_dunning_channels_level", "min_reminder_level between 1 and 20");
table.HasCheckConstraint("ck_platform_dunning_channels_timeout", "timeout_seconds between 1 and 60");
table.HasCheckConstraint("ck_platform_billing_dunning_channels_level", "min_reminder_level between 1 and 20");
table.HasCheckConstraint("ck_platform_billing_dunning_channels_timeout", "timeout_seconds between 1 and 60");
});
}
}
internal sealed class PlatformDunningNotificationEventConfiguration : IEntityTypeConfiguration<PlatformDunningNotificationEvent>
internal sealed class PlatformBillingDunningNotificationEventConfiguration : IEntityTypeConfiguration<PlatformBillingDunningNotificationEvent>
{
public void Configure(EntityTypeBuilder<PlatformDunningNotificationEvent> builder)
public void Configure(EntityTypeBuilder<PlatformBillingDunningNotificationEvent> builder)
{
builder.ConfigureTenantEntity("platform_dunning_notification_events");
builder.ConfigureTenantEntity("platform_billing_dunning_notification_events");
builder.ConfigureTimestamps();
builder.Property(entity => entity.Provider).HasSnakeCaseEnum();
builder.Property(entity => entity.Status).HasSnakeCaseEnum();
@@ -284,15 +141,20 @@ internal sealed class PlatformDunningNotificationEventConfiguration : IEntityTyp
builder.HasIndex(entity => new { entity.ReminderId, entity.Status, entity.CreatedAt });
builder.HasIndex(entity => new { entity.InvoiceId, entity.Status, entity.CreatedAt });
builder.HasIndex(entity => new { entity.TenantId, entity.Status, entity.CreatedAt });
builder.ToTable(table => table.HasCheckConstraint("ck_platform_dunning_events_attempts", "attempts >= 0"));
builder.HasOne<PlatformDunningNotificationChannel>().WithMany().HasForeignKey(entity => entity.ChannelId).OnDelete(DeleteBehavior.Cascade);
builder.HasOne<TenantInvoiceReminder>().WithMany()
builder.ToTable(table => table.HasCheckConstraint("ck_platform_billing_dunning_events_attempts", "attempts >= 0"));
builder.HasOne<PlatformBillingDunningNotificationChannel>().WithMany()
.HasForeignKey(entity => entity.ChannelId)
.HasConstraintName("fk_platform_billing_dunning_events_channel")
.OnDelete(DeleteBehavior.Cascade);
builder.HasOne<PlatformBillingInvoiceReminder>().WithMany()
.HasForeignKey(entity => new { entity.TenantId, entity.ReminderId })
.HasPrincipalKey(entity => new { entity.TenantId, entity.Id })
.HasConstraintName("fk_platform_billing_dunning_events_reminder")
.OnDelete(DeleteBehavior.Cascade);
builder.HasOne<TenantInvoice>().WithMany()
builder.HasOne<PlatformBillingInvoice>().WithMany()
.HasForeignKey(entity => new { entity.TenantId, entity.InvoiceId })
.HasPrincipalKey(entity => new { entity.TenantId, entity.Id })
.HasConstraintName("fk_platform_billing_dunning_events_invoice")
.OnDelete(DeleteBehavior.Cascade);
}
}

View File

@@ -0,0 +1,349 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using Tiku.Domain.Platform;
using Tiku.Domain.Tenancy;
namespace Tiku.Infrastructure.Persistence.Configurations;
internal sealed class SaasFeatureConfiguration : IEntityTypeConfiguration<SaasFeature>
{
public void Configure(EntityTypeBuilder<SaasFeature> builder)
{
builder.ConfigureEntity("saas_features");
builder.ConfigureTimestamps();
builder.Property(value => value.Code).HasMaxLength(120);
builder.Property(value => value.Name).HasMaxLength(200);
builder.Property(value => value.Category).HasMaxLength(100);
builder.Property(value => value.Description).HasMaxLength(1000);
builder.Property(value => value.Currency).HasMaxLength(10);
builder.Property(value => value.Status).HasSnakeCaseEnum();
builder.HasIndex(value => value.Code).IsUnique();
builder.HasIndex(value => new { value.Status, value.Category, value.SortOrder });
builder.ToTable(table => table.HasCheckConstraint("ck_saas_features_reference_price", "reference_price_cents >= 0"));
}
}
internal sealed class PermissionModuleConfiguration : IEntityTypeConfiguration<PermissionModule>
{
public void Configure(EntityTypeBuilder<PermissionModule> builder)
{
builder.ConfigureEntity("permission_modules");
builder.ConfigureTimestamps();
builder.Property(value => value.Code).HasMaxLength(120);
builder.Property(value => value.Name).HasMaxLength(200);
builder.Property(value => value.Area).HasSnakeCaseEnum();
builder.Property(value => value.RequiredFeatureCode).HasMaxLength(120);
builder.Property(value => value.Description).HasMaxLength(1000);
builder.HasIndex(value => value.Code).IsUnique();
builder.HasIndex(value => new { value.Area, value.SortOrder });
builder.HasOne<SaasFeature>().WithMany()
.HasPrincipalKey(value => value.Code)
.HasForeignKey(value => value.RequiredFeatureCode)
.OnDelete(DeleteBehavior.Restrict);
}
}
internal sealed class SaasOfferingConfiguration : IEntityTypeConfiguration<SaasOffering>
{
public void Configure(EntityTypeBuilder<SaasOffering> builder)
{
builder.ConfigureEntity("saas_offerings");
builder.ConfigureTimestamps();
builder.Property(value => value.Code).HasMaxLength(120);
builder.Property(value => value.Name).HasMaxLength(200);
builder.Property(value => value.Type).HasSnakeCaseEnum();
builder.Property(value => value.Status).HasSnakeCaseEnum();
builder.Property(value => value.Description).HasMaxLength(1000);
builder.HasIndex(value => value.Code).IsUnique();
builder.HasIndex(value => new { value.Type, value.Status, value.SortOrder });
}
}
internal sealed class SaasOfferingVersionConfiguration : IEntityTypeConfiguration<SaasOfferingVersion>
{
public void Configure(EntityTypeBuilder<SaasOfferingVersion> builder)
{
builder.ConfigureEntity("saas_offering_versions");
builder.ConfigureTimestamps();
builder.Property(value => value.Status).HasSnakeCaseEnum();
builder.Property(value => value.BillingCycle).HasSnakeCaseEnum();
builder.Property(value => value.Currency).HasMaxLength(10);
builder.Property(value => value.Metadata).IsJson("{}");
builder.HasIndex(value => new { value.OfferingId, value.Version }).IsUnique();
builder.HasIndex(value => new { value.OfferingId, value.Status, value.EffectiveAt });
builder.HasOne<SaasOffering>().WithMany().HasForeignKey(value => value.OfferingId).OnDelete(DeleteBehavior.Cascade);
builder.ToTable(table =>
{
table.HasCheckConstraint("ck_saas_offering_versions_version", "version > 0");
table.HasCheckConstraint("ck_saas_offering_versions_amount", "original_amount_cents >= 0 and amount_cents >= 0 and amount_cents <= original_amount_cents");
});
}
}
internal sealed class SaasOfferingVersionFeatureConfiguration : IEntityTypeConfiguration<SaasOfferingVersionFeature>
{
public void Configure(EntityTypeBuilder<SaasOfferingVersionFeature> builder)
{
builder.ConfigureEntity("saas_offering_version_features");
builder.Property(value => value.FeatureCode).HasMaxLength(120);
builder.HasIndex(value => new { value.OfferingVersionId, value.FeatureCode }).IsUnique();
builder.HasOne<SaasOfferingVersion>().WithMany().HasForeignKey(value => value.OfferingVersionId).OnDelete(DeleteBehavior.Cascade);
builder.HasOne<SaasFeature>().WithMany().HasPrincipalKey(value => value.Code).HasForeignKey(value => value.FeatureCode).OnDelete(DeleteBehavior.Restrict);
}
}
internal sealed class SaasFeatureLimitDefinitionConfiguration : IEntityTypeConfiguration<SaasFeatureLimitDefinition>
{
public void Configure(EntityTypeBuilder<SaasFeatureLimitDefinition> builder)
{
builder.ConfigureEntity("saas_feature_limit_definitions");
builder.ConfigureTimestamps();
builder.Property(value => value.MetricCode).HasMaxLength(120);
builder.Property(value => value.FeatureCode).HasMaxLength(120);
builder.Property(value => value.Name).HasMaxLength(200);
builder.Property(value => value.Unit).HasMaxLength(50);
builder.Property(value => value.Kind).HasSnakeCaseEnum();
builder.HasIndex(value => value.MetricCode).IsUnique();
builder.HasIndex(value => new { value.FeatureCode, value.Kind });
builder.HasOne<SaasFeature>().WithMany().HasPrincipalKey(value => value.Code).HasForeignKey(value => value.FeatureCode).OnDelete(DeleteBehavior.Cascade);
builder.ToTable(table => table.HasCheckConstraint("ck_saas_feature_limit_warning", "warning_percent between 1 and 100"));
}
}
internal sealed class SaasOfferingVersionLimitConfiguration : IEntityTypeConfiguration<SaasOfferingVersionLimit>
{
public void Configure(EntityTypeBuilder<SaasOfferingVersionLimit> builder)
{
builder.ConfigureEntity("saas_offering_version_limits");
builder.Property(value => value.MetricCode).HasMaxLength(120);
builder.HasIndex(value => new { value.OfferingVersionId, value.MetricCode }).IsUnique();
builder.HasOne<SaasOfferingVersion>().WithMany().HasForeignKey(value => value.OfferingVersionId).OnDelete(DeleteBehavior.Cascade);
builder.HasOne<SaasFeatureLimitDefinition>().WithMany().HasPrincipalKey(value => value.MetricCode).HasForeignKey(value => value.MetricCode).OnDelete(DeleteBehavior.Restrict);
builder.ToTable(table => table.HasCheckConstraint("ck_saas_offering_version_limits_value", "limit_value >= 0"));
}
}
internal sealed class TenantSaasSubscriptionConfiguration : IEntityTypeConfiguration<TenantSaasSubscription>
{
public void Configure(EntityTypeBuilder<TenantSaasSubscription> builder)
{
builder.ConfigureTenantEntity("tenant_saas_subscriptions");
builder.ConfigureTimestamps();
builder.Property(value => value.Status).HasSnakeCaseEnum();
builder.Property(value => value.LifecycleVersion).IsConcurrencyToken();
builder.Property(value => value.Metadata).IsJson("{}");
builder.HasIndex(value => new { value.TenantId, value.Status, value.CurrentPeriodEnd });
builder.HasIndex(value => value.TenantId).IsUnique().HasFilter("status in ('trial', 'active', 'past_due', 'suspended')");
builder.HasOne<SaasOfferingVersion>().WithMany().HasForeignKey(value => value.BaseOfferingVersionId).OnDelete(DeleteBehavior.Restrict);
builder.HasOne<SaasOfferingVersion>().WithMany().HasForeignKey(value => value.ScheduledBaseOfferingVersionId).OnDelete(DeleteBehavior.Restrict);
builder.ToTable(table => table.HasCheckConstraint("ck_tenant_saas_subscriptions_period", "current_period_end > current_period_start and current_period_start >= starts_at"));
}
}
internal sealed class TenantSaasSubscriptionItemConfiguration : IEntityTypeConfiguration<TenantSaasSubscriptionItem>
{
public void Configure(EntityTypeBuilder<TenantSaasSubscriptionItem> builder)
{
builder.ConfigureTenantEntity("tenant_saas_subscription_items");
builder.ConfigureTimestamps();
builder.Property(value => value.ItemType).HasSnakeCaseEnum();
builder.Property(value => value.Status).HasSnakeCaseEnum();
builder.HasIndex(value => new { value.TenantId, value.SubscriptionId, value.OfferingVersionId, value.Status });
builder.HasIndex(value => new { value.TenantId, value.SubscriptionId, value.ItemType })
.HasDatabaseName("ux_tenant_saas_subscription_items_current_base")
.IsUnique()
.HasFilter("item_type = 'base_plan' and status = 'active'");
builder.HasIndex(value => new { value.TenantId, value.SubscriptionId, value.Status })
.HasDatabaseName("ux_tenant_saas_subscription_items_scheduled_base")
.IsUnique()
.HasFilter("item_type = 'base_plan' and status = 'scheduled'");
builder.HasOne<TenantSaasSubscription>().WithMany()
.HasForeignKey(value => new { value.TenantId, value.SubscriptionId })
.HasPrincipalKey(value => new { value.TenantId, value.Id })
.OnDelete(DeleteBehavior.Cascade);
builder.HasOne<SaasOfferingVersion>().WithMany().HasForeignKey(value => value.OfferingVersionId).OnDelete(DeleteBehavior.Restrict);
builder.HasOne<PlatformBillingOrderItem>().WithMany()
.HasForeignKey(value => new { value.TenantId, value.SourceOrderItemId })
.HasPrincipalKey(value => new { value.TenantId, value.Id })
.OnDelete(DeleteBehavior.Restrict);
builder.ToTable(table => table.HasCheckConstraint("ck_tenant_saas_subscription_items_period", "ends_at > starts_at"));
}
}
internal sealed class TenantFeatureOverrideConfiguration : IEntityTypeConfiguration<TenantFeatureOverride>
{
public void Configure(EntityTypeBuilder<TenantFeatureOverride> builder)
{
builder.ConfigureTenantEntity("tenant_feature_overrides");
builder.ConfigureTimestamps();
builder.Property(value => value.FeatureCode).HasMaxLength(120);
builder.Property(value => value.Mode).HasSnakeCaseEnum();
builder.Property(value => value.Reason).HasMaxLength(1000);
builder.HasIndex(value => new { value.TenantId, value.FeatureCode }).IsUnique();
builder.HasOne<SaasFeature>().WithMany().HasPrincipalKey(value => value.Code).HasForeignKey(value => value.FeatureCode).OnDelete(DeleteBehavior.Restrict);
}
}
internal sealed class TenantFeatureUsageConfiguration : IEntityTypeConfiguration<TenantFeatureUsage>
{
public void Configure(EntityTypeBuilder<TenantFeatureUsage> builder)
{
builder.ConfigureTenantEntity("tenant_feature_usage");
builder.ConfigureTimestamps();
builder.Property(value => value.MetricCode).HasMaxLength(120);
builder.Property(value => value.Version).IsConcurrencyToken();
builder.HasIndex(value => new { value.TenantId, value.MetricCode, value.PeriodStart, value.PeriodEnd }).IsUnique();
builder.HasOne<SaasFeatureLimitDefinition>().WithMany().HasPrincipalKey(value => value.MetricCode).HasForeignKey(value => value.MetricCode).OnDelete(DeleteBehavior.Restrict);
builder.ToTable(table =>
{
table.HasCheckConstraint("ck_tenant_feature_usage_values", "used_value >= 0 and limit_value_snapshot >= 0");
table.HasCheckConstraint("ck_tenant_feature_usage_period", "period_end > period_start");
});
}
}
internal sealed class PlatformBillingQuoteConfiguration : IEntityTypeConfiguration<PlatformBillingQuote>
{
public void Configure(EntityTypeBuilder<PlatformBillingQuote> builder)
{
builder.ConfigureTenantEntity("platform_billing_quotes");
builder.ConfigureTimestamps();
builder.Property(value => value.QuoteNo).HasMaxLength(100);
builder.Property(value => value.IdempotencyKey).HasMaxLength(200);
builder.Property(value => value.Status).HasSnakeCaseEnum();
builder.Property(value => value.Purpose).HasSnakeCaseEnum();
builder.Property(value => value.Currency).HasMaxLength(10);
builder.Property(value => value.FeatureSnapshot).IsJson("[]");
builder.Property(value => value.LimitSnapshot).IsJson("{}");
builder.HasIndex(value => new { value.TenantId, value.QuoteNo }).IsUnique();
builder.HasIndex(value => new { value.TenantId, value.IdempotencyKey }).IsUnique();
builder.HasIndex(value => new { value.TenantId, value.Status, value.ExpiresAt });
builder.ToTable(table => table.HasCheckConstraint("ck_platform_billing_quotes_amounts", "original_amount_cents >= 0 and discount_amount_cents >= 0 and total_amount_cents >= 0 and total_amount_cents = original_amount_cents - discount_amount_cents"));
}
}
internal sealed class PlatformBillingQuoteItemConfiguration : IEntityTypeConfiguration<PlatformBillingQuoteItem>
{
public void Configure(EntityTypeBuilder<PlatformBillingQuoteItem> builder)
{
builder.ConfigureEntity("platform_billing_quote_items");
builder.HasAlternateKey(value => new { value.TenantId, value.Id });
builder.Property(value => value.ItemType).HasSnakeCaseEnum();
builder.Property(value => value.Snapshot).IsJson("{}");
builder.HasIndex(value => new { value.TenantId, value.QuoteId, value.OfferingVersionId }).IsUnique();
builder.HasOne<Tenant>().WithMany().HasForeignKey(value => value.TenantId).OnDelete(DeleteBehavior.Cascade);
builder.HasOne<PlatformBillingQuote>().WithMany().HasForeignKey(value => new { value.TenantId, value.QuoteId }).HasPrincipalKey(value => new { value.TenantId, value.Id }).OnDelete(DeleteBehavior.Cascade);
builder.HasOne<SaasOfferingVersion>().WithMany().HasForeignKey(value => value.OfferingVersionId).OnDelete(DeleteBehavior.Restrict);
builder.ToTable(table => table.HasCheckConstraint("ck_platform_billing_quote_items_amounts", "quantity > 0 and unit_amount_cents >= 0 and amount_cents >= 0"));
}
}
internal sealed class PlatformBillingOrderConfiguration : IEntityTypeConfiguration<PlatformBillingOrder>
{
public void Configure(EntityTypeBuilder<PlatformBillingOrder> builder)
{
builder.ConfigureTenantEntity("platform_billing_orders");
builder.ConfigureTimestamps();
builder.Property(value => value.OrderNo).HasMaxLength(100);
builder.Property(value => value.IdempotencyKey).HasMaxLength(200);
builder.Property(value => value.Purpose).HasSnakeCaseEnum();
builder.Property(value => value.Status).HasSnakeCaseEnum();
builder.Property(value => value.Currency).HasMaxLength(10);
builder.Property(value => value.Snapshot).IsJson("{}");
builder.HasIndex(value => new { value.TenantId, value.OrderNo }).IsUnique();
builder.HasIndex(value => new { value.TenantId, value.IdempotencyKey }).IsUnique();
builder.HasIndex(value => new { value.TenantId, value.Status, value.CreatedAt });
builder.HasOne<PlatformBillingQuote>().WithMany().HasForeignKey(value => new { value.TenantId, value.QuoteId }).HasPrincipalKey(value => new { value.TenantId, value.Id }).OnDelete(DeleteBehavior.Restrict);
builder.ToTable(table => table.HasCheckConstraint("ck_platform_billing_orders_amounts", "original_amount_cents >= 0 and discount_amount_cents >= 0 and total_amount_cents >= 0 and total_amount_cents = original_amount_cents - discount_amount_cents"));
}
}
internal sealed class PlatformBillingOrderItemConfiguration : IEntityTypeConfiguration<PlatformBillingOrderItem>
{
public void Configure(EntityTypeBuilder<PlatformBillingOrderItem> builder)
{
builder.ConfigureEntity("platform_billing_order_items");
builder.HasAlternateKey(value => new { value.TenantId, value.Id });
builder.Property(value => value.ItemType).HasSnakeCaseEnum();
builder.Property(value => value.Snapshot).IsJson("{}");
builder.HasIndex(value => new { value.TenantId, value.OrderId, value.OfferingVersionId }).IsUnique();
builder.HasOne<Tenant>().WithMany().HasForeignKey(value => value.TenantId).OnDelete(DeleteBehavior.Cascade);
builder.HasOne<PlatformBillingOrder>().WithMany().HasForeignKey(value => new { value.TenantId, value.OrderId }).HasPrincipalKey(value => new { value.TenantId, value.Id }).OnDelete(DeleteBehavior.Cascade);
builder.HasOne<SaasOfferingVersion>().WithMany().HasForeignKey(value => value.OfferingVersionId).OnDelete(DeleteBehavior.Restrict);
builder.ToTable(table => table.HasCheckConstraint("ck_platform_billing_order_items_amounts", "quantity > 0 and unit_amount_cents >= 0 and amount_cents >= 0"));
}
}
internal sealed class PlatformBillingPaymentConfiguration : IEntityTypeConfiguration<PlatformBillingPayment>
{
public void Configure(EntityTypeBuilder<PlatformBillingPayment> builder)
{
builder.ConfigureTenantEntity("platform_billing_payments");
builder.ConfigureTimestamps();
builder.Property(value => value.PaymentNo).HasMaxLength(100);
builder.Property(value => value.IdempotencyKey).HasMaxLength(200);
builder.Property(value => value.Provider).HasMaxLength(50);
builder.Property(value => value.Method).HasMaxLength(50);
builder.Property(value => value.Status).HasSnakeCaseEnum();
builder.Property(value => value.ProviderTradeNo).HasMaxLength(200);
builder.Property(value => value.ClientPayload).IsJson("{}");
builder.HasIndex(value => new { value.TenantId, value.PaymentNo }).IsUnique();
builder.HasIndex(value => new { value.TenantId, value.IdempotencyKey }).IsUnique();
builder.HasIndex(value => new { value.TenantId, value.OrderId, value.Status });
builder.HasOne<PlatformBillingOrder>().WithMany().HasForeignKey(value => new { value.TenantId, value.OrderId }).HasPrincipalKey(value => new { value.TenantId, value.Id }).OnDelete(DeleteBehavior.Cascade);
builder.ToTable(table => table.HasCheckConstraint("ck_platform_billing_payments_amount", "amount_cents >= 0"));
}
}
internal sealed class PlatformBillingPaymentEventConfiguration : IEntityTypeConfiguration<PlatformBillingPaymentEvent>
{
public void Configure(EntityTypeBuilder<PlatformBillingPaymentEvent> builder)
{
builder.ConfigureEntity("platform_billing_payment_events");
builder.HasAlternateKey(value => new { value.TenantId, value.Id });
builder.Property(value => value.Provider).HasMaxLength(50);
builder.Property(value => value.ProviderEventId).HasMaxLength(200);
builder.Property(value => value.EventType).HasMaxLength(100);
builder.Property(value => value.Payload).IsJson("{}");
builder.Property(value => value.CreatedAt).HasDefaultValueSql("now()");
builder.HasIndex(value => new { value.TenantId, value.Provider, value.ProviderEventId }).IsUnique();
builder.HasIndex(value => new { value.TenantId, value.PaymentId, value.CreatedAt });
builder.HasOne<Tenant>().WithMany().HasForeignKey(value => value.TenantId).OnDelete(DeleteBehavior.Cascade);
builder.HasOne<PlatformBillingPayment>().WithMany().HasForeignKey(value => new { value.TenantId, value.PaymentId }).HasPrincipalKey(value => new { value.TenantId, value.Id }).OnDelete(DeleteBehavior.Cascade);
}
}
internal sealed class PlatformBillingRefundConfiguration : IEntityTypeConfiguration<PlatformBillingRefund>
{
public void Configure(EntityTypeBuilder<PlatformBillingRefund> builder)
{
builder.ConfigureTenantEntity("platform_billing_refunds");
builder.ConfigureTimestamps();
builder.Property(value => value.RefundNo).HasMaxLength(100);
builder.Property(value => value.Status).HasSnakeCaseEnum();
builder.Property(value => value.Reason).HasMaxLength(1000);
builder.Property(value => value.ProviderRefundNo).HasMaxLength(200);
builder.HasIndex(value => new { value.TenantId, value.RefundNo }).IsUnique();
builder.HasIndex(value => new { value.TenantId, value.OrderId, value.Status });
builder.HasOne<PlatformBillingOrder>().WithMany().HasForeignKey(value => new { value.TenantId, value.OrderId }).HasPrincipalKey(value => new { value.TenantId, value.Id }).OnDelete(DeleteBehavior.Restrict);
builder.HasOne<PlatformBillingPayment>().WithMany().HasForeignKey(value => new { value.TenantId, value.PaymentId }).HasPrincipalKey(value => new { value.TenantId, value.Id }).OnDelete(DeleteBehavior.Restrict);
builder.ToTable(table => table.HasCheckConstraint("ck_platform_billing_refunds_amount", "amount_cents > 0"));
}
}
internal sealed class PlatformBillingInvoiceConfiguration : IEntityTypeConfiguration<PlatformBillingInvoice>
{
public void Configure(EntityTypeBuilder<PlatformBillingInvoice> builder)
{
builder.ConfigureTenantEntity("platform_billing_invoices");
builder.ConfigureTimestamps();
builder.Property(value => value.InvoiceNo).HasMaxLength(100);
builder.Property(value => value.Status).HasSnakeCaseEnum();
builder.Property(value => value.Currency).HasMaxLength(10);
builder.Property(value => value.BillingProfileSnapshot).IsJson("{}");
builder.HasIndex(value => new { value.TenantId, value.InvoiceNo }).IsUnique();
builder.HasIndex(value => new { value.TenantId, value.Status, value.DueDate });
builder.HasOne<PlatformBillingOrder>().WithMany().HasForeignKey(value => new { value.TenantId, value.OrderId }).HasPrincipalKey(value => new { value.TenantId, value.Id }).OnDelete(DeleteBehavior.Restrict);
builder.ToTable(table => table.HasCheckConstraint("ck_platform_billing_invoices_amount", "total_amount_cents >= 0"));
}
}

View File

@@ -123,6 +123,9 @@ internal sealed class TenantAuthPolicyConfiguration : IEntityTypeConfiguration<T
builder.HasKey(entity => entity.TenantId);
builder.ConfigureTimestamps();
builder.Property(entity => entity.AllowExternalStudentSelfRegistration).HasDefaultValue(false);
builder.Property(entity => entity.AllowedStudentLoginMethods)
.HasColumnType("text[]")
.HasDefaultValueSql("ARRAY['password']::text[]");
builder.HasOne<Tenant>().WithOne()
.HasForeignKey<TenantAuthPolicy>(entity => entity.TenantId)
.OnDelete(DeleteBehavior.Cascade);

View File

@@ -1,213 +0,0 @@
using System;
using System.Text.Json;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Tiku.Infrastructure.Persistence.Migrations
{
/// <inheritdoc />
public partial class AddCommerceAdjustmentVouchers : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "commerce_adjustment_vouchers",
columns: table => new
{
id = table.Column<Guid>(type: "uuid", nullable: false, defaultValueSql: "gen_random_uuid()"),
issue_id = table.Column<Guid>(type: "uuid", nullable: true),
batch_id = table.Column<Guid>(type: "uuid", nullable: true),
item_id = table.Column<Guid>(type: "uuid", nullable: true),
order_id = table.Column<Guid>(type: "uuid", nullable: true),
payment_id = table.Column<Guid>(type: "uuid", nullable: true),
refund_request_id = table.Column<Guid>(type: "uuid", nullable: true),
created_by = table.Column<Guid>(type: "uuid", nullable: true),
reviewed_by = table.Column<Guid>(type: "uuid", nullable: true),
voucher_no = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
status = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
direction = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
amount_cents = table.Column<int>(type: "integer", nullable: false),
currency = table.Column<string>(type: "character varying(10)", maxLength: 10, nullable: false),
reason = table.Column<string>(type: "character varying(1000)", maxLength: 1000, nullable: false),
proof_asset_key = table.Column<string>(type: "character varying(500)", maxLength: 500, nullable: true),
reviewed_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
closed_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
metadata = table.Column<JsonElement>(type: "jsonb", nullable: false, defaultValueSql: "'{}'::jsonb"),
tenant_id = table.Column<Guid>(type: "uuid", nullable: false),
created_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()"),
updated_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()")
},
constraints: table =>
{
table.PrimaryKey("pk_commerce_adjustment_vouchers", x => x.id);
table.UniqueConstraint("ak_commerce_adjustment_vouchers_tenant_id_id", x => new { x.tenant_id, x.id });
table.CheckConstraint("ck_commerce_adjustment_vouchers_amount", "amount_cents > 0");
table.ForeignKey(
name: "fk_commerce_adjustment_vouchers_commerce_reconciliation_batche~",
columns: x => new { x.tenant_id, x.batch_id },
principalTable: "commerce_reconciliation_batches",
principalColumns: new[] { "tenant_id", "id" },
onDelete: ReferentialAction.SetNull);
table.ForeignKey(
name: "fk_commerce_adjustment_vouchers_commerce_reconciliation_issues~",
columns: x => new { x.tenant_id, x.issue_id },
principalTable: "commerce_reconciliation_issues",
principalColumns: new[] { "tenant_id", "id" },
onDelete: ReferentialAction.SetNull);
table.ForeignKey(
name: "fk_commerce_adjustment_vouchers_commerce_reconciliation_items_~",
columns: x => new { x.tenant_id, x.item_id },
principalTable: "commerce_reconciliation_items",
principalColumns: new[] { "tenant_id", "id" },
onDelete: ReferentialAction.SetNull);
table.ForeignKey(
name: "fk_commerce_adjustment_vouchers_commerce_refund_requests_tenan~",
columns: x => new { x.tenant_id, x.refund_request_id },
principalTable: "commerce_refund_requests",
principalColumns: new[] { "tenant_id", "id" },
onDelete: ReferentialAction.SetNull);
table.ForeignKey(
name: "fk_commerce_adjustment_vouchers_orders_tenant_id_order_id",
columns: x => new { x.tenant_id, x.order_id },
principalTable: "orders",
principalColumns: new[] { "tenant_id", "id" },
onDelete: ReferentialAction.SetNull);
table.ForeignKey(
name: "fk_commerce_adjustment_vouchers_payments_tenant_id_payment_id",
columns: x => new { x.tenant_id, x.payment_id },
principalTable: "payments",
principalColumns: new[] { "tenant_id", "id" },
onDelete: ReferentialAction.SetNull);
table.ForeignKey(
name: "fk_commerce_adjustment_vouchers_tenants_tenant_id",
column: x => x.tenant_id,
principalTable: "tenants",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "fk_commerce_adjustment_vouchers_users_created_by",
column: x => x.created_by,
principalTable: "users",
principalColumn: "id",
onDelete: ReferentialAction.SetNull);
table.ForeignKey(
name: "fk_commerce_adjustment_vouchers_users_reviewed_by",
column: x => x.reviewed_by,
principalTable: "users",
principalColumn: "id",
onDelete: ReferentialAction.SetNull);
});
migrationBuilder.CreateTable(
name: "commerce_adjustment_voucher_events",
columns: table => new
{
id = table.Column<Guid>(type: "uuid", nullable: false, defaultValueSql: "gen_random_uuid()"),
tenant_id = table.Column<Guid>(type: "uuid", nullable: false),
voucher_id = table.Column<Guid>(type: "uuid", nullable: false),
from_status = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: true),
to_status = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
actor_user_id = table.Column<Guid>(type: "uuid", nullable: true),
note = table.Column<string>(type: "character varying(1000)", maxLength: 1000, nullable: true),
details = table.Column<JsonElement>(type: "jsonb", nullable: false, defaultValueSql: "'{}'::jsonb"),
created_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()")
},
constraints: table =>
{
table.PrimaryKey("pk_commerce_adjustment_voucher_events", x => x.id);
table.UniqueConstraint("ak_commerce_adjustment_voucher_events_tenant_id_id", x => new { x.tenant_id, x.id });
table.ForeignKey(
name: "fk_commerce_adjustment_voucher_events_commerce_adjustment_vouc~",
columns: x => new { x.tenant_id, x.voucher_id },
principalTable: "commerce_adjustment_vouchers",
principalColumns: new[] { "tenant_id", "id" },
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "fk_commerce_adjustment_voucher_events_tenants_tenant_id",
column: x => x.tenant_id,
principalTable: "tenants",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "fk_commerce_adjustment_voucher_events_users_actor_user_id",
column: x => x.actor_user_id,
principalTable: "users",
principalColumn: "id",
onDelete: ReferentialAction.SetNull);
});
migrationBuilder.CreateIndex(
name: "ix_commerce_adjustment_voucher_events_actor_user_id",
table: "commerce_adjustment_voucher_events",
column: "actor_user_id");
migrationBuilder.CreateIndex(
name: "ix_commerce_adjustment_voucher_events_tenant_id_voucher_id_cre~",
table: "commerce_adjustment_voucher_events",
columns: new[] { "tenant_id", "voucher_id", "created_at" });
migrationBuilder.CreateIndex(
name: "ix_commerce_adjustment_vouchers_created_by",
table: "commerce_adjustment_vouchers",
column: "created_by");
migrationBuilder.CreateIndex(
name: "ix_commerce_adjustment_vouchers_reviewed_by",
table: "commerce_adjustment_vouchers",
column: "reviewed_by");
migrationBuilder.CreateIndex(
name: "ix_commerce_adjustment_vouchers_tenant_id_batch_id",
table: "commerce_adjustment_vouchers",
columns: new[] { "tenant_id", "batch_id" });
migrationBuilder.CreateIndex(
name: "ix_commerce_adjustment_vouchers_tenant_id_issue_id_created_at",
table: "commerce_adjustment_vouchers",
columns: new[] { "tenant_id", "issue_id", "created_at" });
migrationBuilder.CreateIndex(
name: "ix_commerce_adjustment_vouchers_tenant_id_item_id",
table: "commerce_adjustment_vouchers",
columns: new[] { "tenant_id", "item_id" });
migrationBuilder.CreateIndex(
name: "ix_commerce_adjustment_vouchers_tenant_id_order_id",
table: "commerce_adjustment_vouchers",
columns: new[] { "tenant_id", "order_id" });
migrationBuilder.CreateIndex(
name: "ix_commerce_adjustment_vouchers_tenant_id_payment_id",
table: "commerce_adjustment_vouchers",
columns: new[] { "tenant_id", "payment_id" });
migrationBuilder.CreateIndex(
name: "ix_commerce_adjustment_vouchers_tenant_id_refund_request_id",
table: "commerce_adjustment_vouchers",
columns: new[] { "tenant_id", "refund_request_id" });
migrationBuilder.CreateIndex(
name: "ix_commerce_adjustment_vouchers_tenant_id_status_created_at",
table: "commerce_adjustment_vouchers",
columns: new[] { "tenant_id", "status", "created_at" });
migrationBuilder.CreateIndex(
name: "ix_commerce_adjustment_vouchers_tenant_id_voucher_no",
table: "commerce_adjustment_vouchers",
columns: new[] { "tenant_id", "voucher_no" },
unique: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "commerce_adjustment_voucher_events");
migrationBuilder.DropTable(
name: "commerce_adjustment_vouchers");
}
}
}

View File

@@ -1,111 +0,0 @@
using System;
using System.Text.Json;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Tiku.Infrastructure.Persistence.Migrations
{
/// <inheritdoc />
public partial class AddVideoPlaybackProgress : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "video_playback_progress",
columns: table => new
{
id = table.Column<Guid>(type: "uuid", nullable: false, defaultValueSql: "gen_random_uuid()"),
user_id = table.Column<Guid>(type: "uuid", nullable: false),
video_id = table.Column<Guid>(type: "uuid", nullable: false),
question_id = table.Column<Guid>(type: "uuid", nullable: true),
position_seconds = table.Column<int>(type: "integer", nullable: false),
duration_seconds = table.Column<int>(type: "integer", nullable: true),
watched_seconds = table.Column<int>(type: "integer", nullable: false),
is_completed = table.Column<bool>(type: "boolean", nullable: false),
completed_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
last_played_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()"),
play_count = table.Column<int>(type: "integer", nullable: false),
metadata = table.Column<JsonElement>(type: "jsonb", nullable: false, defaultValueSql: "'{}'::jsonb"),
tenant_id = table.Column<Guid>(type: "uuid", nullable: false),
created_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()"),
updated_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()")
},
constraints: table =>
{
table.PrimaryKey("pk_video_playback_progress", x => x.id);
table.UniqueConstraint("ak_video_playback_progress_tenant_id_id", x => new { x.tenant_id, x.id });
table.CheckConstraint("ck_video_playback_progress_duration", "duration_seconds is null or duration_seconds >= 0");
table.CheckConstraint("ck_video_playback_progress_play_count", "play_count >= 0");
table.CheckConstraint("ck_video_playback_progress_position", "position_seconds >= 0");
table.CheckConstraint("ck_video_playback_progress_watched", "watched_seconds >= 0");
table.ForeignKey(
name: "fk_video_playback_progress_questions_tenant_id_question_id",
columns: x => new { x.tenant_id, x.question_id },
principalTable: "questions",
principalColumns: new[] { "tenant_id", "id" },
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "fk_video_playback_progress_tenants_tenant_id",
column: x => x.tenant_id,
principalTable: "tenants",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "fk_video_playback_progress_users_user_id",
column: x => x.user_id,
principalTable: "users",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "fk_video_playback_progress_video_explanations_tenant_id_video_~",
columns: x => new { x.tenant_id, x.video_id },
principalTable: "video_explanations",
principalColumns: new[] { "tenant_id", "id" },
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "ix_video_playback_progress_tenant_id_question_id",
table: "video_playback_progress",
columns: new[] { "tenant_id", "question_id" });
migrationBuilder.CreateIndex(
name: "ix_video_playback_progress_tenant_id_user_id_last_played_at",
table: "video_playback_progress",
columns: new[] { "tenant_id", "user_id", "last_played_at" });
migrationBuilder.CreateIndex(
name: "ix_video_playback_progress_tenant_id_user_id_video_id",
table: "video_playback_progress",
columns: new[] { "tenant_id", "user_id", "video_id" },
unique: true,
filter: "question_id is null");
migrationBuilder.CreateIndex(
name: "ix_video_playback_progress_tenant_id_user_id_video_id_question~",
table: "video_playback_progress",
columns: new[] { "tenant_id", "user_id", "video_id", "question_id" },
unique: true,
filter: "question_id is not null");
migrationBuilder.CreateIndex(
name: "ix_video_playback_progress_tenant_id_video_id",
table: "video_playback_progress",
columns: new[] { "tenant_id", "video_id" });
migrationBuilder.CreateIndex(
name: "ix_video_playback_progress_user_id",
table: "video_playback_progress",
column: "user_id");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "video_playback_progress");
}
}
}

View File

@@ -1,29 +0,0 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Tiku.Infrastructure.Persistence.Migrations
{
/// <inheritdoc />
public partial class RemoveMfaAuthentication : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "mfa_satisfied",
table: "auth_sessions");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "mfa_satisfied",
table: "auth_sessions",
type: "boolean",
nullable: false,
defaultValue: false);
}
}
}

View File

@@ -1,297 +0,0 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace Tiku.Infrastructure.Persistence.Migrations
{
/// <inheritdoc />
public partial class AddDistributedSecurityFoundation : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddUniqueConstraint(
name: "ak_platform_saas_plans_code",
table: "platform_saas_plans",
column: "code");
migrationBuilder.CreateTable(
name: "inbox_state",
columns: table => new
{
id = table.Column<long>(type: "bigint", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
message_id = table.Column<Guid>(type: "uuid", nullable: false),
consumer_id = table.Column<Guid>(type: "uuid", nullable: false),
lock_id = table.Column<Guid>(type: "uuid", nullable: false),
row_version = table.Column<byte[]>(type: "bytea", rowVersion: true, nullable: true),
received = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
receive_count = table.Column<int>(type: "integer", nullable: false),
expiration_time = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
consumed = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
delivered = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
last_sequence_number = table.Column<long>(type: "bigint", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("pk_inbox_state", x => x.id);
table.UniqueConstraint("ak_inbox_state_message_id_consumer_id", x => new { x.message_id, x.consumer_id });
});
migrationBuilder.CreateTable(
name: "outbox_state",
columns: table => new
{
outbox_id = table.Column<Guid>(type: "uuid", nullable: false),
lock_id = table.Column<Guid>(type: "uuid", nullable: false),
row_version = table.Column<byte[]>(type: "bytea", rowVersion: true, nullable: true),
created = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
delivered = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
last_sequence_number = table.Column<long>(type: "bigint", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("pk_outbox_state", x => x.outbox_id);
});
migrationBuilder.CreateTable(
name: "product_modules",
columns: table => new
{
id = table.Column<Guid>(type: "uuid", nullable: false, defaultValueSql: "gen_random_uuid()"),
code = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
name = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
description = table.Column<string>(type: "character varying(1000)", maxLength: 1000, nullable: true),
status = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
sort_order = table.Column<int>(type: "integer", nullable: false),
created_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()"),
updated_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()")
},
constraints: table =>
{
table.PrimaryKey("pk_product_modules", x => x.id);
table.UniqueConstraint("ak_product_modules_code", x => x.code);
});
migrationBuilder.CreateTable(
name: "tenant_auth_policies",
columns: table => new
{
tenant_id = table.Column<Guid>(type: "uuid", nullable: false),
allow_external_student_self_registration = table.Column<bool>(type: "boolean", nullable: false, defaultValue: false),
created_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()"),
updated_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()")
},
constraints: table =>
{
table.PrimaryKey("pk_tenant_auth_policies", x => x.tenant_id);
table.ForeignKey(
name: "fk_tenant_auth_policies_tenants_tenant_id",
column: x => x.tenant_id,
principalTable: "tenants",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
});
// Existing tenants retain the historical external-login behavior. New tenants
// receive an explicit fail-closed policy when created by PlatformAdminService.
migrationBuilder.Sql("""
INSERT INTO tenant_auth_policies
(tenant_id, allow_external_student_self_registration, created_at, updated_at)
SELECT id, TRUE, now(), now()
FROM tenants
ON CONFLICT (tenant_id) DO NOTHING;
""");
migrationBuilder.CreateTable(
name: "outbox_message",
columns: table => new
{
sequence_number = table.Column<long>(type: "bigint", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
enqueue_time = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
sent_time = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
headers = table.Column<string>(type: "text", nullable: true),
properties = table.Column<string>(type: "text", nullable: true),
inbox_message_id = table.Column<Guid>(type: "uuid", nullable: true),
inbox_consumer_id = table.Column<Guid>(type: "uuid", nullable: true),
outbox_id = table.Column<Guid>(type: "uuid", nullable: true),
message_id = table.Column<Guid>(type: "uuid", nullable: false),
content_type = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: false),
message_type = table.Column<string>(type: "text", nullable: false),
body = table.Column<string>(type: "text", nullable: false),
conversation_id = table.Column<Guid>(type: "uuid", nullable: true),
correlation_id = table.Column<Guid>(type: "uuid", nullable: true),
initiator_id = table.Column<Guid>(type: "uuid", nullable: true),
request_id = table.Column<Guid>(type: "uuid", nullable: true),
source_address = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true),
destination_address = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true),
response_address = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true),
fault_address = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true),
expiration_time = table.Column<DateTime>(type: "timestamp with time zone", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("pk_outbox_message", x => x.sequence_number);
table.ForeignKey(
name: "fk_outbox_message_inbox_state_inbox_message_id_inbox_consumer_~",
columns: x => new { x.inbox_message_id, x.inbox_consumer_id },
principalTable: "inbox_state",
principalColumns: new[] { "message_id", "consumer_id" });
table.ForeignKey(
name: "fk_outbox_message_outbox_state_outbox_id",
column: x => x.outbox_id,
principalTable: "outbox_state",
principalColumn: "outbox_id");
});
migrationBuilder.CreateTable(
name: "plan_module_entitlements",
columns: table => new
{
id = table.Column<Guid>(type: "uuid", nullable: false, defaultValueSql: "gen_random_uuid()"),
plan_code = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
module_code = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
enabled = table.Column<bool>(type: "boolean", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("pk_plan_module_entitlements", x => x.id);
table.ForeignKey(
name: "fk_plan_module_entitlements_platform_saas_plans_plan_code",
column: x => x.plan_code,
principalTable: "platform_saas_plans",
principalColumn: "code",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "fk_plan_module_entitlements_product_modules_module_code",
column: x => x.module_code,
principalTable: "product_modules",
principalColumn: "code",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "tenant_module_overrides",
columns: table => new
{
id = table.Column<Guid>(type: "uuid", nullable: false, defaultValueSql: "gen_random_uuid()"),
module_code = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
mode = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
expires_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
reason = table.Column<string>(type: "character varying(1000)", maxLength: 1000, nullable: true),
tenant_id = table.Column<Guid>(type: "uuid", nullable: false),
created_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()"),
updated_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()")
},
constraints: table =>
{
table.PrimaryKey("pk_tenant_module_overrides", x => x.id);
table.UniqueConstraint("ak_tenant_module_overrides_tenant_id_id", x => new { x.tenant_id, x.id });
table.ForeignKey(
name: "fk_tenant_module_overrides_product_modules_module_code",
column: x => x.module_code,
principalTable: "product_modules",
principalColumn: "code",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "fk_tenant_module_overrides_tenants_tenant_id",
column: x => x.tenant_id,
principalTable: "tenants",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "ix_inbox_state_delivered",
table: "inbox_state",
column: "delivered");
migrationBuilder.CreateIndex(
name: "ix_outbox_message_enqueue_time",
table: "outbox_message",
column: "enqueue_time");
migrationBuilder.CreateIndex(
name: "ix_outbox_message_expiration_time",
table: "outbox_message",
column: "expiration_time");
migrationBuilder.CreateIndex(
name: "ix_outbox_message_inbox_message_id_inbox_consumer_id_sequence_~",
table: "outbox_message",
columns: new[] { "inbox_message_id", "inbox_consumer_id", "sequence_number" },
unique: true);
migrationBuilder.CreateIndex(
name: "ix_outbox_message_outbox_id_sequence_number",
table: "outbox_message",
columns: new[] { "outbox_id", "sequence_number" },
unique: true);
migrationBuilder.CreateIndex(
name: "ix_outbox_state_created",
table: "outbox_state",
column: "created");
migrationBuilder.CreateIndex(
name: "ix_plan_module_entitlements_module_code",
table: "plan_module_entitlements",
column: "module_code");
migrationBuilder.CreateIndex(
name: "ix_plan_module_entitlements_plan_code_module_code",
table: "plan_module_entitlements",
columns: new[] { "plan_code", "module_code" },
unique: true);
migrationBuilder.CreateIndex(
name: "ix_product_modules_code",
table: "product_modules",
column: "code",
unique: true);
migrationBuilder.CreateIndex(
name: "ix_tenant_module_overrides_module_code",
table: "tenant_module_overrides",
column: "module_code");
migrationBuilder.CreateIndex(
name: "ix_tenant_module_overrides_tenant_id_module_code",
table: "tenant_module_overrides",
columns: new[] { "tenant_id", "module_code" },
unique: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "outbox_message");
migrationBuilder.DropTable(
name: "plan_module_entitlements");
migrationBuilder.DropTable(
name: "tenant_auth_policies");
migrationBuilder.DropTable(
name: "tenant_module_overrides");
migrationBuilder.DropTable(
name: "inbox_state");
migrationBuilder.DropTable(
name: "outbox_state");
migrationBuilder.DropTable(
name: "product_modules");
migrationBuilder.DropUniqueConstraint(
name: "ak_platform_saas_plans_code",
table: "platform_saas_plans");
}
}
}

View File

@@ -1,64 +0,0 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Tiku.Infrastructure.Persistence.Migrations
{
/// <inheritdoc />
public partial class EnforceProductModuleCatalog : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.Sql("""
INSERT INTO product_modules
(id, code, name, status, sort_order, created_at, updated_at)
VALUES
('10000000-0000-0000-0000-000000000001', 'dashboard', 'Dashboard', 'active', 10, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
('10000000-0000-0000-0000-000000000002', 'staff', 'Staff', 'active', 20, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
('10000000-0000-0000-0000-000000000003', 'role', 'Roles', 'active', 30, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
('10000000-0000-0000-0000-000000000004', 'student', 'Students', 'active', 40, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
('10000000-0000-0000-0000-000000000005', 'content', 'Content', 'active', 50, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
('10000000-0000-0000-0000-000000000006', 'settings', 'Settings', 'active', 60, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
('10000000-0000-0000-0000-000000000007', 'provider', 'Providers', 'active', 70, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
('10000000-0000-0000-0000-000000000008', 'commerce', 'Commerce', 'active', 80, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
('10000000-0000-0000-0000-000000000009', 'crm', 'CRM', 'active', 90, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
('10000000-0000-0000-0000-00000000000a', 'commission', 'Commission', 'active', 100, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
('10000000-0000-0000-0000-00000000000b', 'job', 'Background Jobs', 'active', 110, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
ON CONFLICT (code) DO UPDATE SET
name = EXCLUDED.name,
status = 'active',
sort_order = EXCLUDED.sort_order,
updated_at = CURRENT_TIMESTAMP;
INSERT INTO plan_module_entitlements (id, plan_code, module_code, enabled)
SELECT
md5('plan-module:' || plan.code || ':' || module.code)::uuid,
plan.code,
module.code,
TRUE
FROM platform_saas_plans AS plan
CROSS JOIN product_modules AS module
WHERE module.code IN
('dashboard', 'staff', 'role', 'student', 'content', 'settings',
'provider', 'commerce', 'crm', 'commission', 'job')
AND NOT EXISTS (
SELECT 1
FROM plan_module_entitlements AS existing
WHERE existing.plan_code = plan.code)
ON CONFLICT (plan_code, module_code) DO NOTHING;
""");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.Sql("""
DELETE FROM product_modules
WHERE code IN
('dashboard', 'staff', 'role', 'student', 'content', 'settings',
'provider', 'commerce', 'crm', 'commission', 'job');
""");
}
}
}

View File

@@ -15,4 +15,17 @@ internal static class MigrationBuilderTenantGuardExtensions
{
migrationBuilder.Sql(PostgreSqlTenantConstraintSql.DropTenantGuards);
}
public static void EnsureSaasCatalogGuards(this MigrationBuilder migrationBuilder)
{
migrationBuilder.Sql(PostgreSqlSaasCatalogConstraintSql.DropOfferingVersionImmutabilityGuards);
migrationBuilder.Sql(PostgreSqlSaasCatalogConstraintSql.CreateOfferingVersionImmutabilityGuards);
migrationBuilder.Sql(PostgreSqlSaasCatalogConstraintSql.CreateOfferingVersionChildrenImmutabilityGuards);
migrationBuilder.Sql(PostgreSqlSaasCatalogConstraintSql.CreateSubscriptionOfferingTypeGuards);
}
public static void DropSaasCatalogGuards(this MigrationBuilder migrationBuilder)
{
migrationBuilder.Sql(PostgreSqlSaasCatalogConstraintSql.DropOfferingVersionImmutabilityGuards);
}
}

View File

@@ -0,0 +1,224 @@
namespace Tiku.Infrastructure.Persistence;
internal static class PostgreSqlSaasCatalogConstraintSql
{
public const string CreateOfferingVersionImmutabilityGuards = """
create or replace function tiku_guard_saas_offering_version_immutable()
returns trigger
language plpgsql
as $$
begin
if tg_op = 'DELETE' then
if old.status in ('published', 'retired') then
raise exception 'published or retired SaaS offering versions cannot be deleted'
using errcode = '23514',
constraint = 'ck_saas_offering_versions_published_immutable';
end if;
return old;
end if;
if old.status not in ('published', 'retired') then
return new;
end if;
if old.status = 'published'
and new.status = 'retired'
and new.retired_at is not null
and new.id is not distinct from old.id
and new.offering_id is not distinct from old.offering_id
and new.version is not distinct from old.version
and new.billing_cycle is not distinct from old.billing_cycle
and new.original_amount_cents is not distinct from old.original_amount_cents
and new.amount_cents is not distinct from old.amount_cents
and new.currency is not distinct from old.currency
and new.effective_at is not distinct from old.effective_at
and new.published_at is not distinct from old.published_at
and new.metadata is not distinct from old.metadata
and new.created_at is not distinct from old.created_at then
return new;
end if;
raise exception 'published or retired SaaS offering versions are immutable'
using errcode = '23514',
constraint = 'ck_saas_offering_versions_published_immutable';
end;
$$;
do $guard$
begin
if to_regclass('saas_offering_versions') is not null then
execute 'create trigger trg_saas_offering_versions_published_immutable
before update or delete on saas_offering_versions
for each row execute function tiku_guard_saas_offering_version_immutable()';
end if;
end
$guard$;
""";
public const string CreateOfferingVersionChildrenImmutabilityGuards = """
create or replace function tiku_guard_saas_offering_version_child_immutable()
returns trigger
language plpgsql
as $$
declare
version_status text;
begin
if tg_op = 'DELETE' then
select status into version_status
from saas_offering_versions
where id = old.offering_version_id;
if version_status is distinct from 'draft' then
raise exception 'published or retired SaaS offering version children are immutable'
using errcode = '23514',
constraint = 'ck_saas_offering_versions_published_immutable';
end if;
return old;
end if;
if tg_op = 'UPDATE' and old.offering_version_id <> new.offering_version_id then
select status into version_status
from saas_offering_versions
where id = old.offering_version_id;
if version_status is distinct from 'draft' then
raise exception 'published or retired SaaS offering version children are immutable'
using errcode = '23514',
constraint = 'ck_saas_offering_versions_published_immutable';
end if;
end if;
select status into version_status
from saas_offering_versions
where id = new.offering_version_id;
if version_status is distinct from 'draft' then
raise exception 'published or retired SaaS offering version children are immutable'
using errcode = '23514',
constraint = 'ck_saas_offering_versions_published_immutable';
end if;
return new;
end;
$$;
do $guard$
begin
if to_regclass('saas_offering_version_features') is not null then
execute 'create trigger trg_saas_offering_version_features_published_immutable
before insert or update or delete on saas_offering_version_features
for each row execute function tiku_guard_saas_offering_version_child_immutable()';
end if;
if to_regclass('saas_offering_version_limits') is not null then
execute 'create trigger trg_saas_offering_version_limits_published_immutable
before insert or update or delete on saas_offering_version_limits
for each row execute function tiku_guard_saas_offering_version_child_immutable()';
end if;
end
$guard$;
""";
public const string CreateSubscriptionOfferingTypeGuards = """
create or replace function tiku_guard_saas_subscription_offering_type()
returns trigger
language plpgsql
as $$
declare
offering_type text;
source_version_id uuid;
begin
if tg_table_name = 'tenant_saas_subscriptions' then
select o.type into offering_type
from saas_offering_versions v
join saas_offerings o on o.id = v.offering_id
where v.id = new.base_offering_version_id;
if offering_type is distinct from 'base_plan' then
raise exception 'subscription base offering version must belong to a base plan'
using errcode = '23514', constraint = 'ck_tenant_saas_subscription_base_plan';
end if;
if new.scheduled_base_offering_version_id is not null then
select o.type into offering_type
from saas_offering_versions v
join saas_offerings o on o.id = v.offering_id
where v.id = new.scheduled_base_offering_version_id;
if offering_type is distinct from 'base_plan' then
raise exception 'scheduled subscription offering version must belong to a base plan'
using errcode = '23514', constraint = 'ck_tenant_saas_subscription_base_plan';
end if;
end if;
return new;
end if;
select o.type into offering_type
from saas_offering_versions v
join saas_offerings o on o.id = v.offering_id
where v.id = new.offering_version_id;
if offering_type is distinct from new.item_type::text then
raise exception 'subscription item type must match its offering type'
using errcode = '23514', constraint = 'ck_tenant_saas_subscription_item_offering_type';
end if;
if new.source_order_item_id is not null then
select offering_version_id into source_version_id
from platform_billing_order_items
where tenant_id = new.tenant_id and id = new.source_order_item_id;
if source_version_id is distinct from new.offering_version_id then
raise exception 'subscription item must match its order item snapshot'
using errcode = '23514', constraint = 'ck_tenant_saas_subscription_item_order_snapshot';
end if;
end if;
return new;
end;
$$;
do $guard$
begin
if to_regclass('tenant_saas_subscriptions') is not null then
execute 'create trigger trg_tenant_saas_subscriptions_base_plan
before insert or update on tenant_saas_subscriptions
for each row execute function tiku_guard_saas_subscription_offering_type()';
end if;
if to_regclass('tenant_saas_subscription_items') is not null then
execute 'create trigger trg_tenant_saas_subscription_items_offering_type
before insert or update on tenant_saas_subscription_items
for each row execute function tiku_guard_saas_subscription_offering_type()';
end if;
end
$guard$;
""";
public const string DropOfferingVersionImmutabilityGuards = """
do $guard$
begin
if to_regclass('saas_offering_version_features') is not null then
execute 'drop trigger if exists trg_saas_offering_version_features_published_immutable on saas_offering_version_features';
end if;
if to_regclass('saas_offering_version_limits') is not null then
execute 'drop trigger if exists trg_saas_offering_version_limits_published_immutable on saas_offering_version_limits';
end if;
if to_regclass('saas_offering_versions') is not null then
execute 'drop trigger if exists trg_saas_offering_versions_published_immutable on saas_offering_versions';
end if;
if to_regclass('tenant_saas_subscription_items') is not null then
execute 'drop trigger if exists trg_tenant_saas_subscription_items_offering_type on tenant_saas_subscription_items';
end if;
if to_regclass('tenant_saas_subscriptions') is not null then
execute 'drop trigger if exists trg_tenant_saas_subscriptions_base_plan on tenant_saas_subscriptions';
end if;
end
$guard$;
drop function if exists tiku_guard_saas_offering_version_child_immutable();
drop function if exists tiku_guard_saas_offering_version_immutable();
drop function if exists tiku_guard_saas_subscription_offering_type();
""";
}

View File

@@ -132,8 +132,6 @@ public sealed class TikuDbContext(
public DbSet<ActivationCode> ActivationCodes => Set<ActivationCode>();
public DbSet<Coupon> Coupons => Set<Coupon>();
public DbSet<CouponRedemption> CouponRedemptions => Set<CouponRedemption>();
public DbSet<TenantSubscription> TenantSubscriptions => Set<TenantSubscription>();
public DbSet<TenantUsageRecord> TenantUsageRecords => Set<TenantUsageRecord>();
public DbSet<CommerceRefundRequest> CommerceRefundRequests => Set<CommerceRefundRequest>();
public DbSet<CommerceRefundEvent> CommerceRefundEvents => Set<CommerceRefundEvent>();
public DbSet<CommerceReconciliationBatch> CommerceReconciliationBatches => Set<CommerceReconciliationBatch>();
@@ -180,19 +178,31 @@ public sealed class TikuDbContext(
public DbSet<TenantContentNotification> TenantContentNotifications => Set<TenantContentNotification>();
public DbSet<TenantThemeTemplate> TenantThemeTemplates => Set<TenantThemeTemplate>();
public DbSet<TenantThemeConfig> TenantThemeConfigs => Set<TenantThemeConfig>();
public DbSet<PlatformSaasPlan> PlatformSaasPlans => Set<PlatformSaasPlan>();
public DbSet<ProductModule> ProductModules => Set<ProductModule>();
public DbSet<PlanModuleEntitlement> PlanModuleEntitlements => Set<PlanModuleEntitlement>();
public DbSet<TenantModuleOverride> TenantModuleOverrides => Set<TenantModuleOverride>();
public DbSet<TenantBillingProfile> TenantBillingProfiles => Set<TenantBillingProfile>();
public DbSet<TenantInvoice> TenantInvoices => Set<TenantInvoice>();
public DbSet<TenantInvoiceItem> TenantInvoiceItems => Set<TenantInvoiceItem>();
public DbSet<TenantInvoicePayment> TenantInvoicePayments => Set<TenantInvoicePayment>();
public DbSet<TenantInvoiceReminder> TenantInvoiceReminders => Set<TenantInvoiceReminder>();
public DbSet<PlatformBillingInvoiceReminder> PlatformBillingInvoiceReminders => Set<PlatformBillingInvoiceReminder>();
public DbSet<PlatformAuditAlertRule> PlatformAuditAlertRules => Set<PlatformAuditAlertRule>();
public DbSet<PlatformAuditAlert> PlatformAuditAlerts => Set<PlatformAuditAlert>();
public DbSet<PlatformDunningNotificationChannel> PlatformDunningNotificationChannels => Set<PlatformDunningNotificationChannel>();
public DbSet<PlatformDunningNotificationEvent> PlatformDunningNotificationEvents => Set<PlatformDunningNotificationEvent>();
public DbSet<PlatformBillingDunningNotificationChannel> PlatformBillingDunningNotificationChannels => Set<PlatformBillingDunningNotificationChannel>();
public DbSet<PlatformBillingDunningNotificationEvent> PlatformBillingDunningNotificationEvents => Set<PlatformBillingDunningNotificationEvent>();
public DbSet<SaasFeature> SaasFeatures => Set<SaasFeature>();
public DbSet<PermissionModule> PermissionModules => Set<PermissionModule>();
public DbSet<SaasOffering> SaasOfferings => Set<SaasOffering>();
public DbSet<SaasOfferingVersion> SaasOfferingVersions => Set<SaasOfferingVersion>();
public DbSet<SaasOfferingVersionFeature> SaasOfferingVersionFeatures => Set<SaasOfferingVersionFeature>();
public DbSet<SaasFeatureLimitDefinition> SaasFeatureLimitDefinitions => Set<SaasFeatureLimitDefinition>();
public DbSet<SaasOfferingVersionLimit> SaasOfferingVersionLimits => Set<SaasOfferingVersionLimit>();
public DbSet<TenantSaasSubscription> TenantSaasSubscriptions => Set<TenantSaasSubscription>();
public DbSet<TenantSaasSubscriptionItem> TenantSaasSubscriptionItems => Set<TenantSaasSubscriptionItem>();
public DbSet<TenantFeatureOverride> TenantFeatureOverrides => Set<TenantFeatureOverride>();
public DbSet<TenantFeatureUsage> TenantFeatureUsages => Set<TenantFeatureUsage>();
public DbSet<PlatformBillingQuote> PlatformBillingQuotes => Set<PlatformBillingQuote>();
public DbSet<PlatformBillingQuoteItem> PlatformBillingQuoteItems => Set<PlatformBillingQuoteItem>();
public DbSet<PlatformBillingOrder> PlatformBillingOrders => Set<PlatformBillingOrder>();
public DbSet<PlatformBillingOrderItem> PlatformBillingOrderItems => Set<PlatformBillingOrderItem>();
public DbSet<PlatformBillingPayment> PlatformBillingPayments => Set<PlatformBillingPayment>();
public DbSet<PlatformBillingPaymentEvent> PlatformBillingPaymentEvents => Set<PlatformBillingPaymentEvent>();
public DbSet<PlatformBillingRefund> PlatformBillingRefunds => Set<PlatformBillingRefund>();
public DbSet<PlatformBillingInvoice> PlatformBillingInvoices => Set<PlatformBillingInvoice>();
public DbSet<PocketBaseImportRun> PocketBaseImportRuns => Set<PocketBaseImportRun>();
public DbSet<PocketBaseRawRecord> PocketBaseRawRecords => Set<PocketBaseRawRecord>();
public DbSet<PocketBaseImportIssue> PocketBaseImportIssues => Set<PocketBaseImportIssue>();