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

@@ -6,6 +6,7 @@ using Microsoft.Extensions.DependencyInjection.Extensions;
using Npgsql;
using System.Text.Json;
using Tiku.Application.Commerce;
using Tiku.Application.PlatformBilling;
using Tiku.Application.Auth;
using Tiku.Application.Growth;
using Tiku.Application.Storage;
@@ -29,6 +30,7 @@ public sealed class ApiTestFactory(
IObjectStorageService? objectStorageService = null,
IReferralQrcodeGenerator? referralQrcodeGenerator = null,
IPaymentProviderGateway? paymentProviderGateway = null,
IPlatformBillingPaymentGateway? platformBillingPaymentGateway = null,
IDomainOwnershipVerifier? domainOwnershipVerifier = null,
IDomainGatewayProvisioner? domainGatewayProvisioner = null,
ISmsProvider? smsProvider = null,
@@ -110,6 +112,12 @@ public sealed class ApiTestFactory(
services.AddSingleton(paymentProviderGateway);
}
if (platformBillingPaymentGateway is not null)
{
services.RemoveAll<IPlatformBillingPaymentGateway>();
services.AddSingleton(platformBillingPaymentGateway);
}
if (domainOwnershipVerifier is not null)
{
services.RemoveAll<IDomainOwnershipVerifier>();
@@ -138,54 +146,160 @@ public sealed class ApiTestFactory(
var dbContext = scope.ServiceProvider.GetRequiredService<TikuDbContext>();
var tenants = entities.OfType<Tenant>().Where(tenant => tenant.Mode == TenantMode.Saas).ToArray();
var hasExplicitCapabilitySetup = entities.Any(entity =>
entity is PlatformSaasPlan or ProductModule or PlanModuleEntitlement or TenantSubscription);
entity is SaasOffering or SaasOfferingVersion or TenantSaasSubscription);
if (tenants.Length > 0 && !hasExplicitCapabilitySetup)
{
const string integrationPlanCode = "integration-full-access";
if (!await dbContext.PlatformSaasPlans.AnyAsync(plan => plan.Code == integrationPlanCode))
{
dbContext.PlatformSaasPlans.Add(new PlatformSaasPlan
const string integrationOfferingCode = "integration-full-access";
var existingFeatures = await dbContext.SaasFeatures.Select(feature => feature.Code).ToArrayAsync();
dbContext.SaasFeatures.AddRange(SaasFeatureCatalog.All
.Except(existingFeatures, StringComparer.Ordinal)
.Select((code, index) => new SaasFeature
{
Code = integrationPlanCode,
Name = "Integration Full Access"
});
}
var existingModules = await dbContext.ProductModules
.Select(module => module.Code)
.ToArrayAsync();
foreach (var module in ProductModuleCatalog.All.Where(module => !existingModules.Contains(module.Key)))
Code = code,
Name = code,
Category = code.Split('.')[0],
IsCore = code == SaasFeatureCatalog.CoreBackoffice,
Status = SaasFeatureStatus.Active,
SortOrder = index * 10
}));
var offering = await dbContext.SaasOfferings.SingleOrDefaultAsync(value => value.Code == integrationOfferingCode);
if (offering is null)
{
dbContext.ProductModules.Add(new ProductModule { Code = module.Key, Name = module.Value });
offering = new SaasOffering
{
Code = integrationOfferingCode,
Name = "Integration Full Access",
Type = SaasOfferingType.BasePlan,
Status = SaasOfferingStatus.Active
};
dbContext.SaasOfferings.Add(offering);
}
var version = await dbContext.SaasOfferingVersions.SingleOrDefaultAsync(value =>
value.OfferingId == offering.Id && value.Version == 1);
if (version is null)
{
version = new SaasOfferingVersion
{
OfferingId = offering.Id,
Version = 1,
Status = SaasOfferingVersionStatus.Draft,
OriginalAmountCents = 100,
AmountCents = 100,
EffectiveAt = DateTimeOffset.UtcNow.AddDays(-1)
};
dbContext.SaasOfferingVersions.Add(version);
}
await dbContext.SaveChangesAsync();
var entitledModules = await dbContext.PlanModuleEntitlements
.Where(entitlement => entitlement.PlanCode == integrationPlanCode)
.Select(entitlement => entitlement.ModuleCode)
var entitledFeatures = await dbContext.SaasOfferingVersionFeatures
.Where(entitlement => entitlement.OfferingVersionId == version.Id)
.Select(entitlement => entitlement.FeatureCode)
.ToArrayAsync();
foreach (var moduleCode in ProductModuleCatalog.All.Keys.Except(entitledModules, StringComparer.Ordinal))
{
dbContext.PlanModuleEntitlements.Add(new PlanModuleEntitlement
dbContext.SaasOfferingVersionFeatures.AddRange(SaasFeatureCatalog.All
.Where(code => code != SaasFeatureCatalog.CoreBackoffice)
.Except(entitledFeatures, StringComparer.Ordinal)
.Select(code => new SaasOfferingVersionFeature
{
PlanCode = integrationPlanCode,
ModuleCode = moduleCode
});
}
OfferingVersionId = version.Id,
FeatureCode = code
}));
await dbContext.SaveChangesAsync();
if (version.Status == SaasOfferingVersionStatus.Draft)
{
version.Status = SaasOfferingVersionStatus.Published;
version.PublishedAt = DateTimeOffset.UtcNow.AddDays(-1);
await dbContext.SaveChangesAsync();
}
var now = DateTimeOffset.UtcNow;
entities = entities.Concat(tenants.Select(tenant => new TenantSubscription
var subscriptionGraphs = tenants.SelectMany(tenant =>
{
TenantId = tenant.Id,
PlanCode = integrationPlanCode,
Status = TenantSubscriptionStatus.Active,
StartsAt = now.AddDays(-1),
ExpiresAt = now.AddYears(1)
})).ToArray();
var subscription = new TenantSaasSubscription
{
TenantId = tenant.Id,
BaseOfferingVersionId = version.Id,
Status = TenantSaasSubscriptionStatus.Active,
StartsAt = now.AddDays(-1),
CurrentPeriodStart = now.AddDays(-1),
CurrentPeriodEnd = now.AddYears(1)
};
return new object[]
{
subscription,
new TenantSaasSubscriptionItem
{
TenantId = tenant.Id,
SubscriptionId = subscription.Id,
OfferingVersionId = version.Id,
ItemType = TenantSaasSubscriptionItemType.BasePlan,
Status = TenantSaasSubscriptionItemStatus.Active,
StartsAt = subscription.CurrentPeriodStart,
EndsAt = subscription.CurrentPeriodEnd
}
};
});
entities = entities.Concat(subscriptionGraphs).ToArray();
}
var explicitPermissions = entities.OfType<BackendPermission>().ToArray();
if (explicitPermissions.Length > 0)
{
var moduleCodes = explicitPermissions.Select(value => value.PermissionModuleCode)
.Distinct(StringComparer.Ordinal)
.ToArray();
var existingModuleCodes = await dbContext.PermissionModules
.Where(value => moduleCodes.Contains(value.Code))
.Select(value => value.Code)
.ToArrayAsync();
var suppliedModuleCodes = entities.OfType<PermissionModule>().Select(value => value.Code).ToArray();
var modules = moduleCodes
.Except(existingModuleCodes, StringComparer.Ordinal)
.Except(suppliedModuleCodes, StringComparer.Ordinal)
.Select(code => new PermissionModule
{
Code = code,
Name = code,
Area = explicitPermissions.First(value => value.PermissionModuleCode == code).Area,
RequiredFeatureCode = PermissionModuleCatalog.RequiredFeatures.GetValueOrDefault(code)
})
.ToArray();
var requiredFeatureCodes = modules.Select(value => value.RequiredFeatureCode)
.Where(value => value is not null)
.Cast<string>()
.Distinct(StringComparer.Ordinal)
.ToArray();
var existingRequiredFeatures = await dbContext.SaasFeatures
.Where(value => requiredFeatureCodes.Contains(value.Code))
.Select(value => value.Code)
.ToArrayAsync();
var suppliedFeatureCodes = entities.OfType<SaasFeature>().Select(value => value.Code).ToArray();
dbContext.SaasFeatures.AddRange(requiredFeatureCodes
.Except(existingRequiredFeatures, StringComparer.Ordinal)
.Except(suppliedFeatureCodes, StringComparer.Ordinal)
.Select(code => new SaasFeature
{
Code = code,
Name = code,
Category = code.Split('.')[0],
Status = SaasFeatureStatus.Active
}));
dbContext.PermissionModules.AddRange(modules);
await dbContext.SaveChangesAsync();
}
var offeringVersionsToFinalize = entities.OfType<SaasOfferingVersion>()
.Where(value => value.Status != SaasOfferingVersionStatus.Draft)
.Select(value => new { Version = value, TargetStatus = value.Status })
.ToArray();
foreach (var item in offeringVersionsToFinalize)
{
item.Version.Status = SaasOfferingVersionStatus.Draft;
}
dbContext.AddRange(entities);
await dbContext.SaveChangesAsync();
foreach (var item in offeringVersionsToFinalize)
{
item.Version.Status = item.TargetStatus;
}
await dbContext.SaveChangesAsync();
var backendMembers = entities
.OfType<TenantMembership>()
@@ -206,6 +320,34 @@ public sealed class ApiTestFactory(
IEnumerable<(Guid TenantId, Guid UserId)> members)
{
var permissionCodes = BackendPermissions.Tenant.Order(StringComparer.Ordinal).ToArray();
var featureCodes = PermissionModuleCatalog.RequiredFeatures.Values
.Where(value => value is not null)
.Cast<string>()
.Append(SaasFeatureCatalog.CoreBackoffice)
.Distinct(StringComparer.Ordinal)
.ToArray();
var existingFeatureCodes = await dbContext.SaasFeatures
.Where(value => featureCodes.Contains(value.Code))
.Select(value => value.Code)
.ToArrayAsync();
dbContext.SaasFeatures.AddRange(featureCodes.Except(existingFeatureCodes, StringComparer.Ordinal).Select(code => new SaasFeature
{
Code = code,
Name = code,
Category = code.Split('.')[0],
IsCore = code == SaasFeatureCatalog.CoreBackoffice,
Status = SaasFeatureStatus.Active
}));
var moduleCodes = permissionCodes.Select(PermissionModuleCatalog.ResolvePermissionModuleCode).Distinct(StringComparer.Ordinal).ToArray();
var existingModuleCodes = await dbContext.PermissionModules.Where(value => moduleCodes.Contains(value.Code)).Select(value => value.Code).ToArrayAsync();
dbContext.PermissionModules.AddRange(moduleCodes.Except(existingModuleCodes, StringComparer.Ordinal).Select(code => new PermissionModule
{
Code = code,
Name = code,
Area = BackendPermissionArea.Tenant,
RequiredFeatureCode = PermissionModuleCatalog.RequiredFeatures[code]
}));
await dbContext.SaveChangesAsync();
var existingPermissionCodes = await dbContext.BackendPermissions
.Where(permission => permissionCodes.Contains(permission.Code))
.Select(permission => permission.Code)
@@ -217,7 +359,7 @@ public sealed class ApiTestFactory(
Code = permissionCode,
Name = permissionCode,
Area = BackendPermissionArea.Tenant,
Module = permissionCode.Split(':')[1],
PermissionModuleCode = PermissionModuleCatalog.ResolvePermissionModuleCode(permissionCode),
IsSystem = true
});
}