diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..f81eb4c
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,86 @@
+name: ci
+
+on:
+ pull_request:
+ push:
+ branches: [main]
+
+jobs:
+ release-gate:
+ runs-on: ubuntu-latest
+ services:
+ postgres:
+ image: postgres:17-alpine
+ env:
+ POSTGRES_USER: postgres
+ POSTGRES_PASSWORD: postgres
+ POSTGRES_DB: tiku
+ ports: ['5432:5432']
+ options: >-
+ --health-cmd "pg_isready -U postgres -d tiku"
+ --health-interval 5s
+ --health-timeout 5s
+ --health-retries 20
+ redis:
+ image: redis:7-alpine
+ ports: ['6379:6379']
+ options: >-
+ --health-cmd "redis-cli ping"
+ --health-interval 5s
+ --health-timeout 5s
+ --health-retries 20
+ env:
+ DATABASE_URL: Host=localhost;Port=5432;Database=tiku;Username=postgres;Password=postgres
+ TIKU_TEST_POSTGRES_ADMIN: Host=localhost;Port=5432;Database=postgres;Username=postgres;Password=postgres;Pooling=false;Timeout=5;Command Timeout=60
+ REDIS_URL: localhost:6379,abortConnect=false
+ DOTNET_ENVIRONMENT: Development
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-dotnet@v4
+ with:
+ dotnet-version: 10.0.x
+ - uses: actions/setup-node@v4
+ with:
+ node-version: 24
+ cache: npm
+ cache-dependency-path: Tiku.PlatformAdmin.Web/package-lock.json
+ - name: Restore
+ run: dotnet restore TIKU-BACKEND.slnx
+ - name: Build
+ run: dotnet build TIKU-BACKEND.slnx --no-restore
+ - name: Test
+ run: dotnet test TIKU-BACKEND.slnx --no-build
+ - name: Format
+ run: dotnet format TIKU-BACKEND.slnx --verify-no-changes --no-restore
+ - name: Verify EF model and migration SQL
+ run: |
+ dotnet ef migrations has-pending-model-changes --project Tiku.Infrastructure --startup-project Tiku.DbMigrator --no-build
+ dotnet ef migrations script --idempotent --project Tiku.Infrastructure --startup-project Tiku.DbMigrator --no-build --output /tmp/tiku-migrations.sql
+ test -s /tmp/tiku-migrations.sql
+ - name: Migrate development database
+ run: dotnet run --project Tiku.DbMigrator --no-build
+ - name: Install and check platform frontend
+ working-directory: Tiku.PlatformAdmin.Web
+ run: |
+ npm ci
+ npm run check
+ - name: Verify generated OpenAPI contract
+ run: |
+ dotnet run --project Tiku.Api --no-build --no-launch-profile --urls http://localhost:5090 > /tmp/tiku-api.log 2>&1 &
+ api_pid=$!
+ trap 'kill "$api_pid" 2>/dev/null || true' EXIT
+ for attempt in $(seq 1 60); do
+ curl --fail --silent http://localhost:5090/api/health >/dev/null && break
+ sleep 1
+ done
+ curl --fail --silent http://localhost:5090/openapi/v1.json >/dev/null
+ cd Tiku.PlatformAdmin.Web
+ npm run generate:api
+ cd ..
+ git diff --exit-code -- Tiku.PlatformAdmin.Web/src/api/platform-operations.generated.ts Tiku.PlatformAdmin.Web/src/api/schema.generated.d.ts
+ - name: Build containers
+ run: |
+ docker build -f Tiku.Api/Dockerfile -t tiku-api:ci .
+ docker build -f Tiku.Worker/Dockerfile -t tiku-worker:ci .
+ - name: Verify clean diff formatting
+ run: git diff --check
diff --git a/Tiku.Api/Contracts/AuthDtos.cs b/Tiku.Api/Contracts/AuthDtos.cs
index 81bd621..a790b5f 100644
--- a/Tiku.Api/Contracts/AuthDtos.cs
+++ b/Tiku.Api/Contracts/AuthDtos.cs
@@ -336,3 +336,23 @@ public sealed class AdministrativePasswordResetDto
[Required, StringLength(1000, MinimumLength = 3)]
public string Reason { get; set; } = string.Empty;
}
+
+///
+/// 完成租户负责人一次性激活。
+///
+public sealed class CompleteOwnerActivationDto
+{
+ /// 激活记录 ID。
+ [Required]
+ public Guid ActivationId { get; set; }
+
+ /// 只显示一次的激活令牌。
+ [Required, StringLength(512, MinimumLength = 32)]
+ public string Token { get; set; } = string.Empty;
+
+ /// 符合当前密码策略的新密码。
+ [Required, StringLength(128, MinimumLength = 8)]
+ public string NewPassword { get; set; } = string.Empty;
+
+ public CompleteOwnerActivationRequest ToRequest() => new(ActivationId, Token, NewPassword);
+}
diff --git a/Tiku.Api/Contracts/PlatformAdminDtos.cs b/Tiku.Api/Contracts/PlatformAdminDtos.cs
index 6751442..075cc09 100644
--- a/Tiku.Api/Contracts/PlatformAdminDtos.cs
+++ b/Tiku.Api/Contracts/PlatformAdminDtos.cs
@@ -94,12 +94,56 @@ public sealed class CreatePlatformTenantDto
///
/// 临时密码。
///
- [Required, StringLength(200, MinimumLength = 12)]
- public string TemporaryPassword { get; set; } = string.Empty;
+ [StringLength(200, MinimumLength = 12)]
+ public string? TemporaryPassword { get; set; }
- public CreatePlatformTenantCommand ToCommand() => new(
+ /// 初始试用套餐版本;为空时只创建租户。
+ public Guid? InitialOfferingVersionId { get; set; }
+
+ /// 试用天数。
+ [Range(1, 365)]
+ public int TrialDays { get; set; } = 14;
+
+ /// 收款模式。
+ public TenantBillingCollectionMode CollectionMode { get; set; } = TenantBillingCollectionMode.Online;
+
+ /// 默认支付 Provider。
+ [Required, StringLength(50)]
+ public string DefaultPaymentProvider { get; set; } = "manual";
+
+ /// 是否自动生成续费应收。
+ public bool AutoGenerateRenewal { get; set; } = true;
+
+ /// 续费应收提前生成天数。
+ [Range(1, 90)]
+ public int RenewalLeadDays { get; set; } = 14;
+
+ public CreatePlatformTenantCommand ToCommand(string idempotencyKey, bool allowTemporaryPassword) => new(
Slug, Name, LegalName, Status, BillingStatus, Metadata,
- OwnerEmail, OwnerPhone, OwnerName, TemporaryPassword);
+ OwnerEmail, OwnerPhone, OwnerName, TemporaryPassword,
+ InitialOfferingVersionId, TrialDays, CollectionMode, DefaultPaymentProvider,
+ AutoGenerateRenewal, RenewalLeadDays, idempotencyKey, allowTemporaryPassword);
+}
+
+/// 更新租户收款策略。
+public sealed class UpsertTenantBillingPolicyDto
+{
+ public TenantBillingCollectionMode CollectionMode { get; set; } = TenantBillingCollectionMode.Online;
+ [Required, StringLength(50)]
+ public string DefaultPaymentProvider { get; set; } = "manual";
+ public bool AutoGenerateRenewal { get; set; } = true;
+ [Range(1, 90)]
+ public int RenewalLeadDays { get; set; } = 14;
+ [Required, StringLength(1000, MinimumLength = 3)]
+ public string Reason { get; set; } = string.Empty;
+
+ public UpsertTenantBillingPolicyCommand ToCommand(Guid tenantId) => new(
+ tenantId,
+ CollectionMode,
+ DefaultPaymentProvider,
+ AutoGenerateRenewal,
+ RenewalLeadDays,
+ Reason);
}
///
@@ -117,18 +161,13 @@ public sealed class UpdatePlatformTenantStatusDto
/// 状态。
///
public TenantStatus Status { get; set; } = TenantStatus.Active;
- ///
- /// 账务状态。
- ///
- public BillingStatus BillingStatus { get; set; } = BillingStatus.Active;
-
///
/// 原因。
///
[StringLength(1000)]
public string? Reason { get; set; }
- public UpdatePlatformTenantStatusCommand ToCommand() => new(TenantId, Status, BillingStatus, Reason);
+ public UpdatePlatformTenantStatusCommand ToCommand() => new(TenantId, Status, Reason);
}
///
@@ -450,3 +489,15 @@ public sealed class RetryPlatformBillingDunningEventDto
public RetryPlatformBillingDunningEventCommand ToCommand() => new(EventId, Reason);
}
+
+/// 人工确认或忽略催缴投递事件。
+public sealed class ResolvePlatformBillingDunningEventDto
+{
+ [Required]
+ public Guid EventId { get; set; }
+
+ [Required, StringLength(1000, MinimumLength = 3)]
+ public string Reason { get; set; } = string.Empty;
+
+ public ResolvePlatformBillingDunningEventCommand ToCommand() => new(EventId, Reason);
+}
diff --git a/Tiku.Api/Contracts/SaasBillingDtos.cs b/Tiku.Api/Contracts/SaasBillingDtos.cs
index 8e7a4ce..61c2238 100644
--- a/Tiku.Api/Contracts/SaasBillingDtos.cs
+++ b/Tiku.Api/Contracts/SaasBillingDtos.cs
@@ -149,3 +149,44 @@ public sealed record UpsertTenantFeatureOverrideDto(
{
public UpsertTenantFeatureOverrideCommand ToCommand() => new(TenantId, FeatureCode, Mode, ExpiresAt, Reason);
}
+
+/// 为已有租户补录试用订阅。
+public sealed record GrantTenantTrialDto(
+ Guid TenantId,
+ Guid BaseOfferingVersionId,
+ [Range(1, 365)] int TrialDays,
+ [Required, MaxLength(200)] string IdempotencyKey,
+ [Required, MaxLength(1000)] string Reason)
+{
+ public GrantTenantTrialCommand ToCommand() => new(TenantId, BaseOfferingVersionId, TrialDays, IdempotencyKey, Reason);
+}
+
+/// 平台修改订阅状态。
+public sealed record ChangePlatformSubscriptionDto(
+ [Required, MaxLength(1000)] string Reason,
+ [Range(1, 3650)] int? ExtendDays = null)
+{
+ public ChangePlatformSubscriptionCommand ToCommand(Guid subscriptionId) => new(subscriptionId, Reason, ExtendDays);
+}
+
+/// 申请 SaaS 退款。
+public sealed record RequestPlatformRefundDto(
+ Guid PaymentId,
+ [Range(1, int.MaxValue)] int AmountCents,
+ [Required, MaxLength(1000)] string Reason,
+ [Required, MaxLength(200)] string IdempotencyKey,
+ PlatformBillingRefundSubscriptionEffect SubscriptionEffect)
+{
+ public RequestPlatformRefundCommand ToCommand() => new(
+ PaymentId,
+ AmountCents,
+ Reason,
+ IdempotencyKey,
+ SubscriptionEffect);
+}
+
+/// 审核或重试 SaaS 退款。
+public sealed record ReviewPlatformRefundDto([Required, MaxLength(1000)] string Reason)
+{
+ public ReviewPlatformRefundCommand ToCommand(Guid refundId) => new(refundId, Reason);
+}
diff --git a/Tiku.Api/Controllers/AuthController.cs b/Tiku.Api/Controllers/AuthController.cs
index 4560bf5..d87f86f 100644
--- a/Tiku.Api/Controllers/AuthController.cs
+++ b/Tiku.Api/Controllers/AuthController.cs
@@ -18,6 +18,7 @@ namespace Tiku.Api.Controllers;
[Produces("application/json")]
public sealed class AuthController(
IAuthService authService,
+ IOwnerActivationService ownerActivationService,
ISmsVerificationService smsVerificationService,
IAuthSessionStore sessionStore,
ITenantContext tenantContext,
@@ -26,6 +27,22 @@ public sealed class AuthController(
ICurrentUser currentUser,
IOptions tenantResolutionOptions) : ControllerBase
{
+ [AllowAnonymous]
+ [EnableRateLimiting(AuthRateLimitPolicies.Password)]
+ [HttpPost("activation/complete")]
+ [EndpointSummary("完成租户负责人一次性激活")]
+ [EndpointDescription("使用平台开通时签发的一次性令牌设置初始密码;令牌仅可消费一次。")]
+ [ProducesResponseType(StatusCodes.Status204NoContent)]
+ [ProducesResponseType(StatusCodes.Status400BadRequest)]
+ [ProducesResponseType(StatusCodes.Status409Conflict)]
+ public async Task CompleteOwnerActivation(
+ CompleteOwnerActivationDto request,
+ CancellationToken cancellationToken)
+ {
+ await ownerActivationService.CompleteAsync(request.ToRequest(), cancellationToken);
+ return NoContent();
+ }
+
[AllowAnonymous]
[EnableRateLimiting(AuthRateLimitPolicies.Sms)]
[HttpPost("sms/send")]
diff --git a/Tiku.Api/Controllers/PlatformAdminController.cs b/Tiku.Api/Controllers/PlatformAdminController.cs
index 5028a85..c613976 100644
--- a/Tiku.Api/Controllers/PlatformAdminController.cs
+++ b/Tiku.Api/Controllers/PlatformAdminController.cs
@@ -1,3 +1,4 @@
+using System.ComponentModel.DataAnnotations;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Tiku.Api.Contracts;
@@ -16,7 +17,8 @@ namespace Tiku.Api.Controllers;
public sealed class PlatformAdminController(
IPlatformAdminService platformAdminService,
IAuthAdministrationService authAdministrationService,
- ICurrentUser currentUser) : ControllerBase
+ ICurrentUser currentUser,
+ IHostEnvironment environment) : ControllerBase
{
[HttpGet("overview")]
[EndpointSummary("查询平台经营概览")]
@@ -43,11 +45,30 @@ public sealed class PlatformAdminController(
[ProducesResponseType(StatusCodes.Status200OK)]
public async Task> CreateTenant(
CreatePlatformTenantDto request,
+ [FromHeader(Name = "Idempotency-Key"), Required] string idempotencyKey,
CancellationToken cancellationToken)
{
- return Ok(await platformAdminService.CreateTenantAsync(ResolveActor(), request.ToCommand(), cancellationToken));
+ return Ok(await platformAdminService.CreateTenantAsync(
+ ResolveActor(),
+ request.ToCommand(idempotencyKey, environment.IsDevelopment()),
+ cancellationToken));
}
+ [HttpGet("tenants/{tenantId:guid}/billing-policy")]
+ [Authorize(Policy = BackendPermissions.PlatformTenantManage)]
+ [EndpointSummary("查询租户收款策略")]
+ public Task GetBillingPolicy(Guid tenantId, CancellationToken cancellationToken) =>
+ platformAdminService.GetTenantBillingPolicyAsync(ResolveActor(), tenantId, cancellationToken);
+
+ [HttpPut("tenants/{tenantId:guid}/billing-policy")]
+ [Authorize(Policy = BackendPermissions.PlatformTenantManage)]
+ [EndpointSummary("更新租户收款策略")]
+ public Task UpsertBillingPolicy(
+ Guid tenantId,
+ UpsertTenantBillingPolicyDto request,
+ CancellationToken cancellationToken) =>
+ platformAdminService.UpsertTenantBillingPolicyAsync(ResolveActor(), request.ToCommand(tenantId), cancellationToken);
+
[HttpGet("tenants/detail")]
[Authorize(Policy = BackendPermissions.PlatformTenantManage)]
[EndpointSummary("查询平台租户详情")]
@@ -61,7 +82,7 @@ public sealed class PlatformAdminController(
[HttpPatch("tenants/status")]
[Authorize(Policy = BackendPermissions.PlatformTenantManage)]
- [EndpointSummary("更新租户业务与账务状态")]
+ [EndpointSummary("更新租户业务状态")]
[ProducesResponseType(StatusCodes.Status200OK)]
public async Task> TenantStatus(
UpdatePlatformTenantStatusDto request,
@@ -256,6 +277,26 @@ public sealed class PlatformAdminController(
return Ok(await platformAdminService.RetryBillingDunningEventAsync(ResolveActor(), request.ToCommand(), cancellationToken));
}
+ [HttpPost("saas/dunning/events/acknowledge")]
+ [Authorize(Policy = BackendPermissions.PlatformBillingNotification)]
+ [EndpointSummary("人工确认平台催缴通知事件")]
+ public async Task> AcknowledgeBillingDunningEvent(
+ ResolvePlatformBillingDunningEventDto request,
+ CancellationToken cancellationToken)
+ {
+ return Ok(await platformAdminService.AcknowledgeBillingDunningEventAsync(ResolveActor(), request.ToCommand(), cancellationToken));
+ }
+
+ [HttpPost("saas/dunning/events/ignore")]
+ [Authorize(Policy = BackendPermissions.PlatformBillingNotification)]
+ [EndpointSummary("人工忽略平台催缴通知事件")]
+ public async Task> IgnoreBillingDunningEvent(
+ ResolvePlatformBillingDunningEventDto request,
+ CancellationToken cancellationToken)
+ {
+ return Ok(await platformAdminService.IgnoreBillingDunningEventAsync(ResolveActor(), request.ToCommand(), cancellationToken));
+ }
+
private PlatformAdminActor ResolveActor()
{
if (currentUser.UserId is not { } userId)
diff --git a/Tiku.Api/Controllers/PlatformSaasController.cs b/Tiku.Api/Controllers/PlatformSaasController.cs
index 004fdc2..3974177 100644
--- a/Tiku.Api/Controllers/PlatformSaasController.cs
+++ b/Tiku.Api/Controllers/PlatformSaasController.cs
@@ -120,6 +120,66 @@ public sealed class PlatformSaasController(
public Task UpsertFeatureOverride(UpsertTenantFeatureOverrideDto request, CancellationToken cancellationToken) =>
billingService.UpsertFeatureOverrideAsync(Actor(), request.ToCommand(), cancellationToken);
+ [HttpGet("metrics")]
+ [EndpointSummary("查询 SaaS 商业经营指标")]
+ [Authorize(Policy = BackendPermissions.PlatformSaasBillingManage)]
+ public Task Metrics(CancellationToken cancellationToken) =>
+ billingService.GetCommercialMetricsAsync(Actor(), cancellationToken);
+
+ [HttpPost("subscriptions/trial")]
+ [EndpointSummary("为已有租户补录试用订阅")]
+ [Authorize(Policy = BackendPermissions.PlatformSaasBillingManage)]
+ public Task GrantTrial(GrantTenantTrialDto request, CancellationToken cancellationToken) =>
+ billingService.GrantTrialAsync(Actor(), request.ToCommand(), cancellationToken);
+
+ [HttpPost("subscriptions/{subscriptionId:guid}/suspend")]
+ [EndpointSummary("暂停租户 SaaS 订阅")]
+ [Authorize(Policy = BackendPermissions.PlatformSaasBillingManage)]
+ public Task SuspendSubscription(Guid subscriptionId, ChangePlatformSubscriptionDto request, CancellationToken cancellationToken) =>
+ billingService.SuspendSubscriptionAsync(Actor(), request.ToCommand(subscriptionId), cancellationToken);
+
+ [HttpPost("subscriptions/{subscriptionId:guid}/resume")]
+ [EndpointSummary("恢复租户 SaaS 订阅")]
+ [Authorize(Policy = BackendPermissions.PlatformSaasBillingManage)]
+ public Task ResumeSubscription(Guid subscriptionId, ChangePlatformSubscriptionDto request, CancellationToken cancellationToken) =>
+ billingService.ResumeSubscriptionAsync(Actor(), request.ToCommand(subscriptionId), cancellationToken);
+
+ [HttpPost("subscriptions/{subscriptionId:guid}/cancel")]
+ [EndpointSummary("立即取消租户 SaaS 订阅")]
+ [Authorize(Policy = BackendPermissions.PlatformSaasBillingManage)]
+ public Task CancelSubscription(Guid subscriptionId, ChangePlatformSubscriptionDto request, CancellationToken cancellationToken) =>
+ billingService.CancelSubscriptionAsync(Actor(), request.ToCommand(subscriptionId), cancellationToken);
+
+ [HttpPost("subscriptions/{subscriptionId:guid}/extend")]
+ [EndpointSummary("延长租户 SaaS 订阅账期")]
+ [Authorize(Policy = BackendPermissions.PlatformSaasBillingManage)]
+ public Task ExtendSubscription(Guid subscriptionId, ChangePlatformSubscriptionDto request, CancellationToken cancellationToken) =>
+ billingService.ExtendSubscriptionAsync(Actor(), request.ToCommand(subscriptionId), cancellationToken);
+
+ [HttpPost("refunds")]
+ [EndpointSummary("申请平台 SaaS 退款")]
+ [Authorize(Policy = BackendPermissions.PlatformSaasBillingManage)]
+ public Task RequestRefund(RequestPlatformRefundDto request, CancellationToken cancellationToken) =>
+ billingService.RequestRefundAsync(Actor(), request.ToCommand(), cancellationToken);
+
+ [HttpPost("refunds/{refundId:guid}/approve")]
+ [EndpointSummary("批准平台 SaaS 退款")]
+ [Authorize(Policy = BackendPermissions.PlatformSaasBillingManage)]
+ public Task ApproveRefund(Guid refundId, ReviewPlatformRefundDto request, CancellationToken cancellationToken) =>
+ billingService.ApproveRefundAsync(Actor(), request.ToCommand(refundId), cancellationToken);
+
+ [HttpPost("refunds/{refundId:guid}/reject")]
+ [EndpointSummary("拒绝平台 SaaS 退款")]
+ [Authorize(Policy = BackendPermissions.PlatformSaasBillingManage)]
+ public Task RejectRefund(Guid refundId, ReviewPlatformRefundDto request, CancellationToken cancellationToken) =>
+ billingService.RejectRefundAsync(Actor(), request.ToCommand(refundId), cancellationToken);
+
+ [HttpPost("refunds/{refundId:guid}/retry")]
+ [EndpointSummary("重试失败的平台 SaaS 退款")]
+ [Authorize(Policy = BackendPermissions.PlatformSaasBillingManage)]
+ public Task RetryRefund(Guid refundId, ReviewPlatformRefundDto request, CancellationToken cancellationToken) =>
+ billingService.RetryRefundAsync(Actor(), request.ToCommand(refundId), cancellationToken);
+
private SaasCatalogActor Actor() => currentUser.UserId is { } userId
? new SaasCatalogActor(userId)
: throw new PlatformBillingException("Platform actor was not resolved.", "platform_access_denied");
diff --git a/Tiku.Api/Controllers/TenantBillingController.cs b/Tiku.Api/Controllers/TenantBillingController.cs
index 3ed50c1..dc4dd6c 100644
--- a/Tiku.Api/Controllers/TenantBillingController.cs
+++ b/Tiku.Api/Controllers/TenantBillingController.cs
@@ -40,6 +40,11 @@ public sealed class TenantBillingController(
public async Task> Orders(int limit = 100, CancellationToken cancellationToken = default) =>
await billingService.GetOrdersAsync(await ActorAsync(cancellationToken), limit, cancellationToken);
+ [HttpPost("orders/{orderNo}/cancel")]
+ [EndpointSummary("取消待支付租户账务订单")]
+ public async Task CancelOrder(string orderNo, CancellationToken cancellationToken) =>
+ await billingService.CancelOrderAsync(await ActorAsync(cancellationToken), orderNo, cancellationToken);
+
[HttpGet("orders/{orderNo}")]
[EndpointSummary("查询租户账务订单详情")]
public async Task Order(string orderNo, CancellationToken cancellationToken) =>
@@ -75,6 +80,16 @@ public sealed class TenantBillingController(
public async Task> Invoices(int limit = 100, CancellationToken cancellationToken = default) =>
await billingService.GetInvoicesAsync(await ActorAsync(cancellationToken), limit, cancellationToken);
+ [HttpGet("receivables")]
+ [EndpointSummary("查询当前租户应收账单")]
+ public async Task> Receivables(int limit = 100, CancellationToken cancellationToken = default) =>
+ await billingService.GetReceivablesAsync(await ActorAsync(cancellationToken), limit, cancellationToken);
+
+ [HttpGet("refunds")]
+ [EndpointSummary("查询当前租户 SaaS 退款")]
+ public async Task> Refunds(int limit = 100, CancellationToken cancellationToken = default) =>
+ await billingService.GetRefundsAsync(await ActorAsync(cancellationToken), limit, cancellationToken);
+
private async Task ActorAsync(CancellationToken cancellationToken)
{
var access = await accessContext.GetAsync(cancellationToken);
diff --git a/Tiku.Api/Middleware/ExceptionHandlingMiddleware.cs b/Tiku.Api/Middleware/ExceptionHandlingMiddleware.cs
index c616993..2473e89 100644
--- a/Tiku.Api/Middleware/ExceptionHandlingMiddleware.cs
+++ b/Tiku.Api/Middleware/ExceptionHandlingMiddleware.cs
@@ -52,6 +52,15 @@ public sealed class ExceptionHandlingMiddleware(
return;
}
+ if (exception is OwnerActivationException ownerActivationException)
+ {
+ var status = ownerActivationException.Code is "owner_activation_consumed"
+ ? StatusCodes.Status409Conflict
+ : StatusCodes.Status400BadRequest;
+ await WriteProblemAsync(context, ownerActivationException.Message, status, ownerActivationException.Code);
+ return;
+ }
+
if (exception is TenantNotFoundException)
{
await WriteProblemAsync(
@@ -588,7 +597,7 @@ public sealed class ExceptionHandlingMiddleware(
return code switch
{
"platform_access_denied" => StatusCodes.Status403Forbidden,
- "tenant_slug_exists" => StatusCodes.Status409Conflict,
+ "tenant_slug_exists" or "idempotency_conflict" => StatusCodes.Status409Conflict,
_ when code.EndsWith("_not_found", StringComparison.Ordinal) => StatusCodes.Status404NotFound,
_ => StatusCodes.Status400BadRequest
};
@@ -603,7 +612,11 @@ public sealed class ExceptionHandlingMiddleware(
"payment_secret_not_configured" => StatusCodes.Status503ServiceUnavailable,
"saas_offering_version_immutable" or "saas_offering_version_status_invalid" or
"platform_billing_quote_expired" or "platform_billing_order_status_invalid" or
- "platform_billing_payment_status_invalid" or "platform_billing_payment_amount_mismatch" =>
+ "platform_billing_payment_status_invalid" or "platform_billing_payment_amount_mismatch" or
+ "platform_billing_order_not_cancellable" or "platform_billing_payment_not_refundable" or
+ "platform_billing_refund_amount_invalid" or "platform_billing_refund_not_retryable" or
+ "platform_billing_refund_status_invalid" or "tenant_saas_subscription_status_invalid" or
+ "tenant_saas_subscription_exists" or "idempotency_conflict" =>
StatusCodes.Status409Conflict,
_ when code.EndsWith("_not_found", StringComparison.Ordinal) => StatusCodes.Status404NotFound,
_ => StatusCodes.Status400BadRequest
diff --git a/Tiku.Application/Auth/IOwnerActivationService.cs b/Tiku.Application/Auth/IOwnerActivationService.cs
new file mode 100644
index 0000000..327b3a2
--- /dev/null
+++ b/Tiku.Application/Auth/IOwnerActivationService.cs
@@ -0,0 +1,18 @@
+namespace Tiku.Application.Auth;
+
+public sealed record CompleteOwnerActivationRequest(
+ Guid ActivationId,
+ string Token,
+ string NewPassword);
+
+public interface IOwnerActivationService
+{
+ Task CompleteAsync(
+ CompleteOwnerActivationRequest request,
+ CancellationToken cancellationToken = default);
+}
+
+public sealed class OwnerActivationException(string message, string code) : Exception(message)
+{
+ public string Code { get; } = code;
+}
diff --git a/Tiku.Application/Commerce/PaymentProviderContracts.cs b/Tiku.Application/Commerce/PaymentProviderContracts.cs
index 42e579f..dae1590 100644
--- a/Tiku.Application/Commerce/PaymentProviderContracts.cs
+++ b/Tiku.Application/Commerce/PaymentProviderContracts.cs
@@ -55,6 +55,21 @@ public sealed record PaymentNotificationResult(
DateTimeOffset? PaidAt,
JsonElement RawPayload);
+public sealed record CreateRefundProviderRequest(
+ Guid TenantId,
+ string OrderNo,
+ string RefundNo,
+ string? ProviderTradeNo,
+ int AmountCents,
+ string Reason,
+ JsonElement Metadata);
+
+public sealed record CreateRefundProviderResult(
+ string Provider,
+ bool Succeeded,
+ string? ProviderRefundNo,
+ JsonElement RawPayload);
+
public interface IPaymentProvider
{
string Provider { get; }
@@ -68,6 +83,14 @@ public interface IPaymentProvider
PaymentProviderAccount account,
PaymentNotificationRequest request,
CancellationToken cancellationToken = default);
+
+ Task CreateRefundAsync(
+ PaymentProviderAccount account,
+ CreateRefundProviderRequest request,
+ CancellationToken cancellationToken = default) =>
+ Task.FromException(new PaymentProviderException(
+ "Payment provider refund is not configured.",
+ "payment_provider_refund_not_supported"));
}
public interface IPaymentProviderGateway
diff --git a/Tiku.Application/PlatformAdmin/PlatformAdminModels.cs b/Tiku.Application/PlatformAdmin/PlatformAdminModels.cs
index 663a189..9d9b171 100644
--- a/Tiku.Application/PlatformAdmin/PlatformAdminModels.cs
+++ b/Tiku.Application/PlatformAdmin/PlatformAdminModels.cs
@@ -40,7 +40,8 @@ public sealed record PlatformTenantDetail(
PlatformTenantItem Tenant,
IReadOnlyCollection Domains,
IReadOnlyCollection Subscriptions,
- TenantBillingProfileItem? BillingProfile);
+ TenantBillingProfileItem? BillingProfile,
+ TenantBillingPolicyItem? BillingPolicy);
public sealed record PlatformTenantDomainItem(
Guid Id,
@@ -90,18 +91,46 @@ public sealed record CreatePlatformTenantCommand(
string? OwnerEmail,
string? OwnerPhone,
string OwnerName,
- string TemporaryPassword);
+ string? TemporaryPassword,
+ Guid? InitialOfferingVersionId,
+ int TrialDays,
+ TenantBillingCollectionMode CollectionMode,
+ string DefaultPaymentProvider,
+ bool AutoGenerateRenewal,
+ int RenewalLeadDays,
+ string IdempotencyKey,
+ bool AllowTemporaryPassword);
public sealed record PlatformTenantProvisioningResult(
PlatformTenantItem Tenant,
Guid OwnerUserId,
string OwnerIdentifier,
- bool MustChangePassword);
+ bool MustChangePassword,
+ Guid? ActivationId,
+ string? ActivationToken,
+ DateTimeOffset? ActivationExpiresAt,
+ bool IsReplay);
+
+public sealed record TenantBillingPolicyItem(
+ Guid TenantId,
+ TenantBillingCollectionMode CollectionMode,
+ string DefaultPaymentProvider,
+ bool AutoGenerateRenewal,
+ int RenewalLeadDays,
+ DateTimeOffset CreatedAt,
+ DateTimeOffset UpdatedAt);
+
+public sealed record UpsertTenantBillingPolicyCommand(
+ Guid TenantId,
+ TenantBillingCollectionMode CollectionMode,
+ string DefaultPaymentProvider,
+ bool AutoGenerateRenewal,
+ int RenewalLeadDays,
+ string Reason);
public sealed record UpdatePlatformTenantStatusCommand(
Guid TenantId,
TenantStatus Status,
- BillingStatus BillingStatus,
string? Reason);
public sealed record UpsertPlatformTenantBillingProfileCommand(
@@ -227,6 +256,7 @@ public sealed record PlatformBillingDunningEventItem(
DateTimeOffset UpdatedAt);
public sealed record RetryPlatformBillingDunningEventCommand(Guid EventId, string? Reason);
+public sealed record ResolvePlatformBillingDunningEventCommand(Guid EventId, string Reason);
public interface IPlatformAdminService
{
@@ -236,6 +266,8 @@ public interface IPlatformAdminService
Task CreateTenantAsync(PlatformAdminActor actor, CreatePlatformTenantCommand command, CancellationToken cancellationToken = default);
Task UpdateTenantStatusAsync(PlatformAdminActor actor, UpdatePlatformTenantStatusCommand command, CancellationToken cancellationToken = default);
Task UpsertTenantBillingProfileAsync(PlatformAdminActor actor, UpsertPlatformTenantBillingProfileCommand command, CancellationToken cancellationToken = default);
+ Task GetTenantBillingPolicyAsync(PlatformAdminActor actor, Guid tenantId, CancellationToken cancellationToken = default);
+ Task UpsertTenantBillingPolicyAsync(PlatformAdminActor actor, UpsertTenantBillingPolicyCommand command, CancellationToken cancellationToken = default);
Task GetDomainsAsync(PlatformAdminActor actor, PlatformAdminQuery query, CancellationToken cancellationToken = default);
Task RecheckDomainAsync(PlatformAdminActor actor, Guid domainId, CancellationToken cancellationToken = default);
Task GetStaffAsync(PlatformAdminActor actor, PlatformAdminQuery query, CancellationToken cancellationToken = default);
@@ -250,6 +282,8 @@ public interface IPlatformAdminService
Task GetBillingDunningEventsAsync(PlatformAdminActor actor, PlatformAdminQuery query, CancellationToken cancellationToken = default);
Task GetBillingDunningEventDetailAsync(PlatformAdminActor actor, Guid eventId, CancellationToken cancellationToken = default);
Task RetryBillingDunningEventAsync(PlatformAdminActor actor, RetryPlatformBillingDunningEventCommand command, CancellationToken cancellationToken = default);
+ Task AcknowledgeBillingDunningEventAsync(PlatformAdminActor actor, ResolvePlatformBillingDunningEventCommand command, CancellationToken cancellationToken = default);
+ Task IgnoreBillingDunningEventAsync(PlatformAdminActor actor, ResolvePlatformBillingDunningEventCommand command, CancellationToken cancellationToken = default);
}
public sealed class PlatformAdminException(string message, string code) : Exception(message)
diff --git a/Tiku.Application/PlatformBilling/PlatformBillingContracts.cs b/Tiku.Application/PlatformBilling/PlatformBillingContracts.cs
index 6b3d534..2f7f14b 100644
--- a/Tiku.Application/PlatformBilling/PlatformBillingContracts.cs
+++ b/Tiku.Application/PlatformBilling/PlatformBillingContracts.cs
@@ -175,11 +175,45 @@ public interface ITenantBillingService
Task CancelSubscriptionAsync(TenantBillingActor actor, CancellationToken cancellationToken = default);
Task> GetUsageAsync(TenantBillingActor actor, CancellationToken cancellationToken = default);
Task> GetInvoicesAsync(TenantBillingActor actor, int limit, CancellationToken cancellationToken = default);
+ Task> GetReceivablesAsync(TenantBillingActor actor, int limit, CancellationToken cancellationToken = default);
+ Task CancelOrderAsync(TenantBillingActor actor, string orderNo, CancellationToken cancellationToken = default);
+ Task> GetRefundsAsync(TenantBillingActor actor, int limit, CancellationToken cancellationToken = default);
}
public sealed record PlatformBillingAdminQuery(Guid? TenantId, string? Status, int Limit = 100);
public sealed record ConfirmManualPaymentCommand(Guid PaymentId, string? ProviderTradeNo, DateTimeOffset? PaidAt, string Reason);
public sealed record UpsertTenantFeatureOverrideCommand(Guid TenantId, string FeatureCode, TenantFeatureOverrideMode Mode, DateTimeOffset? ExpiresAt, string Reason);
+public sealed record GrantTenantTrialCommand(Guid TenantId, Guid BaseOfferingVersionId, int TrialDays, string IdempotencyKey, string Reason);
+public sealed record ChangePlatformSubscriptionCommand(Guid SubscriptionId, string Reason, int? ExtendDays = null);
+public sealed record RequestPlatformRefundCommand(
+ Guid PaymentId,
+ int AmountCents,
+ string Reason,
+ string IdempotencyKey,
+ PlatformBillingRefundSubscriptionEffect SubscriptionEffect);
+public sealed record ReviewPlatformRefundCommand(Guid RefundId, string Reason);
+public sealed record CommercialMetrics(
+ int ActiveSubscriptions,
+ int TrialSubscriptions,
+ int PastDueSubscriptions,
+ int MrrCents,
+ int ArrCents,
+ int CollectedCents,
+ int OutstandingCents,
+ int OverdueCents,
+ int RefundedCents,
+ int ChurnedSubscriptions);
+
+public sealed record TenantReceivableView(
+ Guid Id,
+ Guid? OrderId,
+ string InvoiceNo,
+ PlatformBillingInvoiceStatus Status,
+ int TotalAmountCents,
+ string Currency,
+ DateOnly? DueDate,
+ DateTimeOffset? IssuedAt,
+ DateTimeOffset? PaidAt);
public interface IPlatformBillingAdminService
{
@@ -192,12 +226,41 @@ public interface IPlatformBillingAdminService
Task> GetSubscriptionsAsync(SaasCatalogActor actor, PlatformBillingAdminQuery query, CancellationToken cancellationToken = default);
Task ConfirmManualPaymentAsync(SaasCatalogActor actor, ConfirmManualPaymentCommand command, CancellationToken cancellationToken = default);
Task UpsertFeatureOverrideAsync(SaasCatalogActor actor, UpsertTenantFeatureOverrideCommand command, CancellationToken cancellationToken = default);
+ Task GrantTrialAsync(SaasCatalogActor actor, GrantTenantTrialCommand command, CancellationToken cancellationToken = default);
+ Task SuspendSubscriptionAsync(SaasCatalogActor actor, ChangePlatformSubscriptionCommand command, CancellationToken cancellationToken = default);
+ Task ResumeSubscriptionAsync(SaasCatalogActor actor, ChangePlatformSubscriptionCommand command, CancellationToken cancellationToken = default);
+ Task CancelSubscriptionAsync(SaasCatalogActor actor, ChangePlatformSubscriptionCommand command, CancellationToken cancellationToken = default);
+ Task ExtendSubscriptionAsync(SaasCatalogActor actor, ChangePlatformSubscriptionCommand command, CancellationToken cancellationToken = default);
+ Task RequestRefundAsync(SaasCatalogActor actor, RequestPlatformRefundCommand command, CancellationToken cancellationToken = default);
+ Task ApproveRefundAsync(SaasCatalogActor actor, ReviewPlatformRefundCommand command, CancellationToken cancellationToken = default);
+ Task RejectRefundAsync(SaasCatalogActor actor, ReviewPlatformRefundCommand command, CancellationToken cancellationToken = default);
+ Task RetryRefundAsync(SaasCatalogActor actor, ReviewPlatformRefundCommand command, CancellationToken cancellationToken = default);
+ Task GetCommercialMetricsAsync(SaasCatalogActor actor, CancellationToken cancellationToken = default);
}
public interface IPlatformBillingPaymentGateway
{
Task CreatePaymentAsync(string provider, CreatePaymentProviderRequest request, CancellationToken cancellationToken = default);
Task ParseNotificationAsync(string provider, PaymentNotificationRequest request, CancellationToken cancellationToken = default);
+ Task CreateRefundAsync(
+ string provider,
+ CreateRefundProviderRequest request,
+ CancellationToken cancellationToken = default) =>
+ Task.FromException(new PlatformBillingException(
+ "Platform refund gateway is not configured.",
+ "platform_billing_refund_provider_not_supported"));
+}
+
+public interface ICommercialBillingProcessor
+{
+ Task ProcessDueAsync(CancellationToken cancellationToken = default);
+}
+
+public sealed class CommercialBillingOptions
+{
+ public bool Enabled { get; set; } = true;
+ public int BatchSize { get; set; } = 100;
+ public string[] AllowedWebhookHosts { get; set; } = [];
}
public sealed record PlatformBillingNotification(
diff --git a/Tiku.Application/Security/ITenantExecutionScope.cs b/Tiku.Application/Security/ITenantExecutionScope.cs
index f13ba25..26df60a 100644
--- a/Tiku.Application/Security/ITenantExecutionScope.cs
+++ b/Tiku.Application/Security/ITenantExecutionScope.cs
@@ -6,6 +6,7 @@ public enum SystemScopeCallerType
Worker,
Migrator,
PublicQuestionBank,
+ Anonymous,
Test
}
diff --git a/Tiku.DbMigrator/Program.cs b/Tiku.DbMigrator/Program.cs
index 3c896ac..4e84539 100644
--- a/Tiku.DbMigrator/Program.cs
+++ b/Tiku.DbMigrator/Program.cs
@@ -36,6 +36,7 @@ var connectionString =
"Database connection is required outside Development. Configure ConnectionStrings:Database or DATABASE_URL."));
builder.Services.AddApplication();
+builder.Services.AddAuthentication();
builder.Services.AddInfrastructure(
connectionString,
isDevelopment && !bootstrapPlatformAdmin
diff --git a/Tiku.Domain/Platform/PlatformOperationsEntities.cs b/Tiku.Domain/Platform/PlatformOperationsEntities.cs
index a441830..4836ea9 100644
--- a/Tiku.Domain/Platform/PlatformOperationsEntities.cs
+++ b/Tiku.Domain/Platform/PlatformOperationsEntities.cs
@@ -21,6 +21,35 @@ public sealed class TenantBillingProfile : IHasTimestamps, ITenantOwned
public DateTimeOffset UpdatedAt { get; set; } = DateTimeOffset.UtcNow;
}
+public sealed class TenantBillingPolicy : IHasTimestamps, ITenantOwned
+{
+ public Guid TenantId { get; set; }
+ public TenantBillingCollectionMode CollectionMode { get; set; } = TenantBillingCollectionMode.Online;
+ public string DefaultPaymentProvider { get; set; } = "manual";
+ public bool AutoGenerateRenewal { get; set; } = true;
+ public int RenewalLeadDays { get; set; } = 14;
+ public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
+ public DateTimeOffset UpdatedAt { get; set; } = DateTimeOffset.UtcNow;
+}
+
+public sealed class TenantOwnerActivationGrant : AuditableTenantEntity
+{
+ public Guid UserId { get; set; }
+ public Guid CreatedBy { get; set; }
+ public string TokenHash { get; set; } = string.Empty;
+ public DateTimeOffset ExpiresAt { get; set; }
+ public DateTimeOffset? ConsumedAt { get; set; }
+}
+
+public sealed class PlatformOperationIdempotency : AuditableEntity
+{
+ public Guid ActorUserId { get; set; }
+ public string Scope { get; set; } = string.Empty;
+ public string IdempotencyKey { get; set; } = string.Empty;
+ public string RequestHash { get; set; } = string.Empty;
+ public Guid ResourceId { get; set; }
+}
+
public sealed class PlatformBillingInvoiceReminder : AuditableTenantEntity
{
public Guid InvoiceId { get; set; }
@@ -142,6 +171,7 @@ public enum PlatformBillingInvoiceReminderStatus { Pending, Sent, Acknowledged,
public enum PlatformAlertSeverity { Low, Medium, High, Critical }
public enum PlatformAuditAlertStatus { Open, Acknowledged, Resolved, Ignored }
public enum PlatformBillingDunningProvider { Generic, Dingtalk, Feishu, Wecom }
-public enum PlatformBillingDunningNotificationStatus { Pending, Processing, Sent, Retrying, Failed, Discarded }
+public enum PlatformBillingDunningNotificationStatus { Pending, Processing, Sent, Retrying, Failed, Discarded, Acknowledged, Ignored }
public enum PlatformPaymentAppStatus { Active, Disabled, Testing }
public enum PlatformPaymentChannelStatus { Active, Disabled, Testing }
+public enum TenantBillingCollectionMode { Online, Manual }
diff --git a/Tiku.Domain/Platform/SaasBillingEntities.cs b/Tiku.Domain/Platform/SaasBillingEntities.cs
index 6a59ce7..c3213af 100644
--- a/Tiku.Domain/Platform/SaasBillingEntities.cs
+++ b/Tiku.Domain/Platform/SaasBillingEntities.cs
@@ -211,6 +211,13 @@ public sealed class PlatformBillingRefund : AuditableTenantEntity
public string Reason { get; set; } = string.Empty;
public string? ProviderRefundNo { get; set; }
public DateTimeOffset? CompletedAt { get; set; }
+ public string IdempotencyKey { get; set; } = string.Empty;
+ public PlatformBillingRefundSubscriptionEffect SubscriptionEffect { get; set; } = PlatformBillingRefundSubscriptionEffect.KeepService;
+ public Guid? RequestedBy { get; set; }
+ public Guid? ReviewedBy { get; set; }
+ public DateTimeOffset? ReviewedAt { get; set; }
+ public string? ReviewReason { get; set; }
+ public string? LastError { get; set; }
}
public sealed class PlatformBillingInvoice : AuditableTenantEntity
@@ -242,3 +249,4 @@ public enum PlatformBillingItemType { BasePlan, AddOn }
public enum PlatformBillingPaymentStatus { Pending, Succeeded, Failed, Refunded }
public enum PlatformBillingRefundStatus { Requested, Processing, Succeeded, Failed, Cancelled }
public enum PlatformBillingInvoiceStatus { Draft, Issued, Paid, Void, Overdue }
+public enum PlatformBillingRefundSubscriptionEffect { KeepService, CancelAtPeriodEnd, TerminateImmediately }
diff --git a/Tiku.Domain/Tenancy/Tenant.cs b/Tiku.Domain/Tenancy/Tenant.cs
index 3454f2c..5cbec10 100644
--- a/Tiku.Domain/Tenancy/Tenant.cs
+++ b/Tiku.Domain/Tenancy/Tenant.cs
@@ -36,5 +36,6 @@ public enum BillingStatus
Trial,
Active,
PastDue,
+ Suspended,
Cancelled
}
diff --git a/Tiku.Infrastructure/Auth/OwnerActivationService.cs b/Tiku.Infrastructure/Auth/OwnerActivationService.cs
new file mode 100644
index 0000000..ed39f2c
--- /dev/null
+++ b/Tiku.Infrastructure/Auth/OwnerActivationService.cs
@@ -0,0 +1,96 @@
+using System.Security.Cryptography;
+using System.Text;
+using Microsoft.AspNetCore.Identity;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.DependencyInjection;
+using Tiku.Application.Auth;
+using Tiku.Application.Security;
+using Tiku.Domain.Identity;
+using Tiku.Domain.Operations;
+using Tiku.Infrastructure.Persistence;
+
+namespace Tiku.Infrastructure.Auth;
+
+internal sealed class OwnerActivationService(
+ ITenantExecutionScope tenantExecutionScope) : IOwnerActivationService
+{
+ public Task CompleteAsync(
+ CompleteOwnerActivationRequest request,
+ CancellationToken cancellationToken = default) =>
+ tenantExecutionScope.ExecuteAsync(
+ new SystemScopeRequest(
+ null,
+ SystemScopeCallerType.Anonymous,
+ nameof(OwnerActivationService),
+ "Complete tenant owner activation",
+ request.ActivationId.ToString("N"),
+ true),
+ async (services, token) =>
+ {
+ var dbContext = services.GetRequiredService();
+ var grant = await dbContext.TenantOwnerActivationGrants
+ .AsNoTracking()
+ .SingleOrDefaultAsync(value => value.Id == request.ActivationId, token)
+ ?? throw Error("Owner activation was not found.", "owner_activation_invalid");
+ var now = DateTimeOffset.UtcNow;
+ var tokenHash = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(request.Token))).ToLowerInvariant();
+ if (grant.ConsumedAt.HasValue)
+ {
+ throw Error("Owner activation was already consumed.", "owner_activation_consumed");
+ }
+ if (grant.ExpiresAt <= now || !CryptographicOperations.FixedTimeEquals(
+ Convert.FromHexString(grant.TokenHash),
+ Convert.FromHexString(tokenHash)))
+ {
+ throw Error("Owner activation is invalid or expired.", "owner_activation_invalid");
+ }
+
+ var claimed = await dbContext.TenantOwnerActivationGrants
+ .Where(value => value.Id == grant.Id &&
+ value.ConsumedAt == null &&
+ value.ExpiresAt > now &&
+ value.TokenHash == tokenHash)
+ .ExecuteUpdateAsync(setters => setters
+ .SetProperty(value => value.ConsumedAt, now)
+ .SetProperty(value => value.UpdatedAt, now), token);
+ if (claimed != 1)
+ {
+ throw Error("Owner activation is invalid or already consumed.", "owner_activation_consumed");
+ }
+
+ var userManager = services.GetRequiredService>();
+ var user = await userManager.FindByIdAsync(grant.UserId.ToString())
+ ?? throw Error("Owner account was not found.", "owner_activation_invalid");
+ if (await userManager.HasPasswordAsync(user))
+ {
+ throw Error("Owner account was already activated.", "owner_activation_consumed");
+ }
+
+ var result = await userManager.AddPasswordAsync(user, request.NewPassword);
+ if (!result.Succeeded)
+ {
+ throw Error(
+ string.Join("; ", result.Errors.Select(error => error.Description)),
+ "owner_activation_password_invalid");
+ }
+ user.ForcePasswordChange = false;
+ var updateResult = await userManager.UpdateAsync(user);
+ if (!updateResult.Succeeded)
+ {
+ throw Error("Owner account activation could not be completed.", "owner_activation_failed");
+ }
+ await userManager.UpdateSecurityStampAsync(user);
+ dbContext.AuditLogs.Add(new AuditLog
+ {
+ TenantId = grant.TenantId,
+ ActorUserId = grant.UserId,
+ Action = "tenant.owner.activated",
+ TargetType = "users",
+ TargetId = grant.UserId.ToString()
+ });
+ await dbContext.SaveChangesAsync(token);
+ },
+ cancellationToken);
+
+ private static OwnerActivationException Error(string message, string code) => new(message, code);
+}
diff --git a/Tiku.Infrastructure/Commerce/ManualPaymentProvider.cs b/Tiku.Infrastructure/Commerce/ManualPaymentProvider.cs
index c1f98bd..563f990 100644
--- a/Tiku.Infrastructure/Commerce/ManualPaymentProvider.cs
+++ b/Tiku.Infrastructure/Commerce/ManualPaymentProvider.cs
@@ -38,4 +38,24 @@ internal sealed class ManualPaymentProvider : IPaymentProvider
"Manual payment does not accept provider notifications.",
"manual_payment_notification_not_supported");
}
+
+ public Task CreateRefundAsync(
+ PaymentProviderAccount account,
+ CreateRefundProviderRequest request,
+ CancellationToken cancellationToken = default)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ var payload = JsonSerializer.SerializeToElement(new
+ {
+ request.OrderNo,
+ request.RefundNo,
+ request.AmountCents,
+ request.Reason
+ });
+ return Task.FromResult(new CreateRefundProviderResult(
+ Provider,
+ true,
+ $"manual-{request.RefundNo}",
+ payload));
+ }
}
diff --git a/Tiku.Infrastructure/DependencyInjection.cs b/Tiku.Infrastructure/DependencyInjection.cs
index 6574b81..06b3f38 100644
--- a/Tiku.Infrastructure/DependencyInjection.cs
+++ b/Tiku.Infrastructure/DependencyInjection.cs
@@ -114,6 +114,7 @@ public static class DependencyInjection
services.AddScoped();
services.AddScoped();
services.AddScoped();
+ services.AddScoped();
services.AddScoped();
services.AddScoped();
services.AddScoped();
@@ -145,6 +146,7 @@ public static class DependencyInjection
services.AddScoped();
services.AddScoped();
services.AddScoped();
+ services.AddHttpClient();
services.AddScoped();
services.AddScoped();
services.AddScoped();
diff --git a/Tiku.Infrastructure/Persistence/Configurations/PlatformOperationsConfigurations.cs b/Tiku.Infrastructure/Persistence/Configurations/PlatformOperationsConfigurations.cs
index 71a936f..3f0c28b 100644
--- a/Tiku.Infrastructure/Persistence/Configurations/PlatformOperationsConfigurations.cs
+++ b/Tiku.Infrastructure/Persistence/Configurations/PlatformOperationsConfigurations.cs
@@ -28,6 +28,50 @@ internal sealed class TenantBillingProfileConfiguration : IEntityTypeConfigurati
}
}
+internal sealed class TenantBillingPolicyConfiguration : IEntityTypeConfiguration
+{
+ public void Configure(EntityTypeBuilder builder)
+ {
+ builder.ToTable("tenant_billing_policies");
+ builder.HasKey(entity => entity.TenantId);
+ builder.ConfigureTimestamps();
+ builder.Property(entity => entity.CollectionMode).HasSnakeCaseEnum();
+ builder.Property(entity => entity.DefaultPaymentProvider).HasMaxLength(50);
+ builder.ToTable(table => table.HasCheckConstraint(
+ "ck_tenant_billing_policies_renewal_lead_days",
+ "renewal_lead_days between 1 and 90"));
+ builder.HasOne().WithOne().HasForeignKey(entity => entity.TenantId).OnDelete(DeleteBehavior.Cascade);
+ }
+}
+
+internal sealed class TenantOwnerActivationGrantConfiguration : IEntityTypeConfiguration
+{
+ public void Configure(EntityTypeBuilder builder)
+ {
+ builder.ConfigureTenantEntity("tenant_owner_activation_grants");
+ builder.ConfigureTimestamps();
+ builder.Property(entity => entity.TokenHash).HasMaxLength(64);
+ builder.HasIndex(entity => new { entity.TenantId, entity.UserId, entity.ConsumedAt, entity.ExpiresAt });
+ builder.HasIndex(entity => entity.TokenHash).IsUnique().HasAnnotation("Tiku:GlobalUnique", true);
+ builder.HasOne().WithMany().HasForeignKey(entity => entity.UserId).OnDelete(DeleteBehavior.Cascade);
+ builder.HasOne().WithMany().HasForeignKey(entity => entity.CreatedBy).OnDelete(DeleteBehavior.Restrict);
+ }
+}
+
+internal sealed class PlatformOperationIdempotencyConfiguration : IEntityTypeConfiguration
+{
+ public void Configure(EntityTypeBuilder builder)
+ {
+ builder.ConfigureEntity("platform_operation_idempotencies");
+ builder.ConfigureTimestamps();
+ builder.Property(entity => entity.Scope).HasMaxLength(100);
+ builder.Property(entity => entity.IdempotencyKey).HasMaxLength(200);
+ builder.Property(entity => entity.RequestHash).HasMaxLength(64);
+ builder.HasIndex(entity => new { entity.ActorUserId, entity.Scope, entity.IdempotencyKey }).IsUnique();
+ builder.HasOne().WithMany().HasForeignKey(entity => entity.ActorUserId).OnDelete(DeleteBehavior.Restrict);
+ }
+}
+
internal sealed class PlatformBillingInvoiceReminderConfiguration : IEntityTypeConfiguration
{
public void Configure(EntityTypeBuilder builder)
diff --git a/Tiku.Infrastructure/Persistence/Configurations/SaasBillingConfigurations.cs b/Tiku.Infrastructure/Persistence/Configurations/SaasBillingConfigurations.cs
index 2d99b62..42d9811 100644
--- a/Tiku.Infrastructure/Persistence/Configurations/SaasBillingConfigurations.cs
+++ b/Tiku.Infrastructure/Persistence/Configurations/SaasBillingConfigurations.cs
@@ -323,7 +323,12 @@ internal sealed class PlatformBillingRefundConfiguration : IEntityTypeConfigurat
builder.Property(value => value.Status).HasSnakeCaseEnum();
builder.Property(value => value.Reason).HasMaxLength(1000);
builder.Property(value => value.ProviderRefundNo).HasMaxLength(200);
+ builder.Property(value => value.IdempotencyKey).HasMaxLength(200);
+ builder.Property(value => value.SubscriptionEffect).HasSnakeCaseEnum();
+ builder.Property(value => value.ReviewReason).HasMaxLength(1000);
+ builder.Property(value => value.LastError).HasMaxLength(2000);
builder.HasIndex(value => new { value.TenantId, value.RefundNo }).IsUnique();
+ builder.HasIndex(value => new { value.TenantId, value.IdempotencyKey }).IsUnique();
builder.HasIndex(value => new { value.TenantId, value.OrderId, value.Status });
builder.HasOne().WithMany().HasForeignKey(value => new { value.TenantId, value.OrderId }).HasPrincipalKey(value => new { value.TenantId, value.Id }).OnDelete(DeleteBehavior.Restrict);
builder.HasOne().WithMany().HasForeignKey(value => new { value.TenantId, value.PaymentId }).HasPrincipalKey(value => new { value.TenantId, value.Id }).OnDelete(DeleteBehavior.Restrict);
diff --git a/Tiku.Infrastructure/Persistence/Migrations/20260801063209_CommercialDeliveryClosure.Designer.cs b/Tiku.Infrastructure/Persistence/Migrations/20260801063209_CommercialDeliveryClosure.Designer.cs
new file mode 100644
index 0000000..c44aca3
--- /dev/null
+++ b/Tiku.Infrastructure/Persistence/Migrations/20260801063209_CommercialDeliveryClosure.Designer.cs
@@ -0,0 +1,20434 @@
+//
+using System;
+using System.Text.Json;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
+using Tiku.Infrastructure.Persistence;
+
+#nullable disable
+
+namespace Tiku.Infrastructure.Persistence.Migrations
+{
+ [DbContext(typeof(TikuDbContext))]
+ [Migration("20260801063209_CommercialDeliveryClosure")]
+ partial class CommercialDeliveryClosure
+ {
+ ///
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder
+ .HasAnnotation("ProductVersion", "10.0.10")
+ .HasAnnotation("Relational:MaxIdentifierLength", 63);
+
+ NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "citext");
+ NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "ltree");
+ NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "pg_trgm");
+ NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
+
+ modelBuilder.Entity("Microsoft.AspNetCore.DataProtection.EntityFrameworkCore.DataProtectionKey", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer")
+ .HasColumnName("id");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("FriendlyName")
+ .HasColumnType("text")
+ .HasColumnName("friendly_name");
+
+ b.Property("Xml")
+ .HasColumnType("text")
+ .HasColumnName("xml");
+
+ b.HasKey("Id")
+ .HasName("pk_data_protection_keys");
+
+ b.ToTable("data_protection_keys", (string)null);
+ });
+
+ modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer")
+ .HasColumnName("id");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("ClaimType")
+ .HasColumnType("text")
+ .HasColumnName("claim_type");
+
+ b.Property("ClaimValue")
+ .HasColumnType("text")
+ .HasColumnName("claim_value");
+
+ b.Property("UserId")
+ .HasColumnType("uuid")
+ .HasColumnName("user_id");
+
+ b.HasKey("Id")
+ .HasName("pk_user_claims");
+
+ b.HasIndex("UserId")
+ .HasDatabaseName("ix_user_claims_user_id");
+
+ b.ToTable("user_claims", (string)null);
+ });
+
+ modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b =>
+ {
+ b.Property("LoginProvider")
+ .HasColumnType("text")
+ .HasColumnName("login_provider");
+
+ b.Property("ProviderKey")
+ .HasColumnType("text")
+ .HasColumnName("provider_key");
+
+ b.Property("ProviderDisplayName")
+ .HasColumnType("text")
+ .HasColumnName("provider_display_name");
+
+ b.Property("UserId")
+ .HasColumnType("uuid")
+ .HasColumnName("user_id");
+
+ b.HasKey("LoginProvider", "ProviderKey")
+ .HasName("pk_user_logins");
+
+ b.HasIndex("UserId")
+ .HasDatabaseName("ix_user_logins_user_id");
+
+ b.ToTable("user_logins", (string)null);
+ });
+
+ modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b =>
+ {
+ b.Property("UserId")
+ .HasColumnType("uuid")
+ .HasColumnName("user_id");
+
+ b.Property("LoginProvider")
+ .HasColumnType("text")
+ .HasColumnName("login_provider");
+
+ b.Property("Name")
+ .HasColumnType("text")
+ .HasColumnName("name");
+
+ b.Property("Value")
+ .HasColumnType("text")
+ .HasColumnName("value");
+
+ b.HasKey("UserId", "LoginProvider", "Name")
+ .HasName("pk_user_tokens");
+
+ b.ToTable("user_tokens", (string)null);
+ });
+
+ modelBuilder.Entity("Tiku.Domain.Catalog.Category", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id")
+ .HasDefaultValueSql("gen_random_uuid()");
+
+ b.Property("CategoryType")
+ .HasMaxLength(32)
+ .HasColumnType("character varying(32)")
+ .HasColumnName("category_type");
+
+ b.Property("CreatedAt")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at")
+ .HasDefaultValueSql("now()");
+
+ b.Property("IsActive")
+ .HasColumnType("boolean")
+ .HasColumnName("is_active");
+
+ b.Property("LegacyId")
+ .HasMaxLength(64)
+ .HasColumnType("character varying(64)")
+ .HasColumnName("legacy_id");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(300)
+ .HasColumnType("character varying(300)")
+ .HasColumnName("name");
+
+ b.Property("NodeId")
+ .HasColumnType("uuid")
+ .HasColumnName("node_id");
+
+ b.Property("SortOrder")
+ .HasColumnType("integer")
+ .HasColumnName("sort_order");
+
+ b.Property("SubjectId")
+ .HasColumnType("uuid")
+ .HasColumnName("subject_id");
+
+ b.Property("SvipQuestionLimit")
+ .HasColumnType("integer")
+ .HasColumnName("svip_question_limit");
+
+ b.Property("TenantId")
+ .HasColumnType("uuid")
+ .HasColumnName("tenant_id");
+
+ b.Property("UpdatedAt")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("updated_at")
+ .HasDefaultValueSql("now()");
+
+ b.HasKey("Id")
+ .HasName("pk_categories");
+
+ b.HasAlternateKey("TenantId", "Id")
+ .HasName("ak_categories_tenant_id_id");
+
+ b.HasIndex("TenantId", "LegacyId")
+ .IsUnique()
+ .HasDatabaseName("ix_categories_tenant_id_legacy_id");
+
+ b.HasIndex("TenantId", "NodeId")
+ .HasDatabaseName("ix_categories_tenant_id_node_id");
+
+ b.HasIndex("TenantId", "SubjectId")
+ .HasDatabaseName("ix_categories_tenant_id_subject_id");
+
+ b.ToTable("categories", (string)null);
+ });
+
+ modelBuilder.Entity("Tiku.Domain.Catalog.Major", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id")
+ .HasDefaultValueSql("gen_random_uuid()");
+
+ b.Property("CreatedAt")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at")
+ .HasDefaultValueSql("now()");
+
+ b.Property("Description")
+ .HasColumnType("text")
+ .HasColumnName("description");
+
+ b.Property("IsActive")
+ .HasColumnType("boolean")
+ .HasColumnName("is_active");
+
+ b.Property("LegacyId")
+ .HasMaxLength(64)
+ .HasColumnType("character varying(64)")
+ .HasColumnName("legacy_id");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(300)
+ .HasColumnType("character varying(300)")
+ .HasColumnName("name");
+
+ b.Property("RegionId")
+ .HasColumnType("uuid")
+ .HasColumnName("region_id");
+
+ b.Property("SchoolId")
+ .HasColumnType("uuid")
+ .HasColumnName("school_id");
+
+ b.Property("SortOrder")
+ .HasColumnType("integer")
+ .HasColumnName("sort_order");
+
+ b.Property("StudyTips")
+ .HasColumnType("text")
+ .HasColumnName("study_tips");
+
+ b.Property("TenantId")
+ .HasColumnType("uuid")
+ .HasColumnName("tenant_id");
+
+ b.Property("UpdatedAt")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("updated_at")
+ .HasDefaultValueSql("now()");
+
+ b.HasKey("Id")
+ .HasName("pk_majors");
+
+ b.HasAlternateKey("TenantId", "Id")
+ .HasName("ak_majors_tenant_id_id");
+
+ b.HasIndex("TenantId", "LegacyId")
+ .IsUnique()
+ .HasDatabaseName("ix_majors_tenant_id_legacy_id");
+
+ b.HasIndex("TenantId", "RegionId")
+ .HasDatabaseName("ix_majors_tenant_id_region_id");
+
+ b.HasIndex("TenantId", "SchoolId")
+ .HasDatabaseName("ix_majors_tenant_id_school_id");
+
+ b.ToTable("majors", (string)null);
+ });
+
+ modelBuilder.Entity("Tiku.Domain.Catalog.ModuleNode", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id")
+ .HasDefaultValueSql("gen_random_uuid()");
+
+ b.Property("CreatedAt")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at")
+ .HasDefaultValueSql("now()");
+
+ b.Property("IsActive")
+ .HasColumnType("boolean")
+ .HasColumnName("is_active");
+
+ b.Property("LegacyId")
+ .HasMaxLength(64)
+ .HasColumnType("character varying(64)")
+ .HasColumnName("legacy_id");
+
+ b.Property("LegacyModuleId")
+ .HasMaxLength(64)
+ .HasColumnType("character varying(64)")
+ .HasColumnName("legacy_module_id");
+
+ b.Property("LegacyParentId")
+ .HasMaxLength(64)
+ .HasColumnType("character varying(64)")
+ .HasColumnName("legacy_parent_id");
+
+ b.Property("Metadata")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("jsonb")
+ .HasColumnName("metadata")
+ .HasDefaultValueSql("'{}'::jsonb");
+
+ b.Property("ModuleId")
+ .HasColumnType("uuid")
+ .HasColumnName("module_id");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(300)
+ .HasColumnType("character varying(300)")
+ .HasColumnName("name");
+
+ b.Property("ParentId")
+ .HasColumnType("uuid")
+ .HasColumnName("parent_id");
+
+ b.Property("Path")
+ .HasMaxLength(1000)
+ .HasColumnType("character varying(1000)")
+ .HasColumnName("path");
+
+ b.Property("RegionId")
+ .HasColumnType("uuid")
+ .HasColumnName("region_id");
+
+ b.Property("SortOrder")
+ .HasColumnType("integer")
+ .HasColumnName("sort_order");
+
+ b.Property("TenantId")
+ .HasColumnType("uuid")
+ .HasColumnName("tenant_id");
+
+ b.Property("Type")
+ .IsRequired()
+ .HasMaxLength(32)
+ .HasColumnType("character varying(32)")
+ .HasColumnName("type");
+
+ b.Property("UpdatedAt")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("updated_at")
+ .HasDefaultValueSql("now()");
+
+ b.HasKey("Id")
+ .HasName("pk_module_nodes");
+
+ b.HasAlternateKey("TenantId", "Id")
+ .HasName("ak_module_nodes_tenant_id_id");
+
+ b.HasIndex("TenantId", "LegacyId")
+ .IsUnique()
+ .HasDatabaseName("ix_module_nodes_tenant_id_legacy_id");
+
+ b.HasIndex("TenantId", "ModuleId")
+ .HasDatabaseName("ix_module_nodes_tenant_id_module_id");
+
+ b.HasIndex("TenantId", "ParentId")
+ .HasDatabaseName("ix_module_nodes_tenant_id_parent_id");
+
+ b.HasIndex("TenantId", "RegionId")
+ .HasDatabaseName("ix_module_nodes_tenant_id_region_id");
+
+ b.ToTable("module_nodes", (string)null);
+ });
+
+ modelBuilder.Entity("Tiku.Domain.Catalog.QuestionTaxonomyAssignment", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id")
+ .HasDefaultValueSql("gen_random_uuid()");
+
+ b.Property("CreatedAt")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at")
+ .HasDefaultValueSql("now()");
+
+ b.Property("IsPrimary")
+ .HasColumnType("boolean")
+ .HasColumnName("is_primary");
+
+ b.Property("QuestionId")
+ .HasColumnType("uuid")
+ .HasColumnName("question_id");
+
+ b.Property("TaxonomyNodeId")
+ .HasColumnType("uuid")
+ .HasColumnName("taxonomy_node_id");
+
+ b.Property("TaxonomyOwnerTenantId")
+ .HasColumnType("uuid")
+ .HasColumnName("taxonomy_owner_tenant_id");
+
+ b.Property("TenantId")
+ .HasColumnType("uuid")
+ .HasColumnName("tenant_id");
+
+ b.HasKey("Id")
+ .HasName("pk_question_taxonomy_assignments");
+
+ b.HasAlternateKey("TenantId", "Id")
+ .HasName("ak_question_taxonomy_assignments_tenant_id_id");
+
+ b.HasIndex("TaxonomyOwnerTenantId", "TaxonomyNodeId")
+ .HasDatabaseName("ix_question_taxonomy_assignments_taxonomy_owner_tenant_id_taxo~");
+
+ b.HasIndex("TenantId", "QuestionId", "TaxonomyOwnerTenantId", "TaxonomyNodeId")
+ .IsUnique()
+ .HasDatabaseName("ix_question_taxonomy_assignments_tenant_id_question_id_taxonom~");
+
+ b.ToTable("question_taxonomy_assignments", (string)null);
+ });
+
+ modelBuilder.Entity("Tiku.Domain.Catalog.Region", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id")
+ .HasDefaultValueSql("gen_random_uuid()");
+
+ b.Property("Code")
+ .HasMaxLength(50)
+ .HasColumnType("character varying(50)")
+ .HasColumnName("code");
+
+ b.Property("Config")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("jsonb")
+ .HasColumnName("config")
+ .HasDefaultValueSql("'{}'::jsonb");
+
+ b.Property("CreatedAt")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at")
+ .HasDefaultValueSql("now()");
+
+ b.Property("FullName")
+ .HasMaxLength(300)
+ .HasColumnType("character varying(300)")
+ .HasColumnName("full_name");
+
+ b.Property("Icon")
+ .HasMaxLength(2048)
+ .HasColumnType("character varying(2048)")
+ .HasColumnName("icon");
+
+ b.Property("IsActive")
+ .HasColumnType("boolean")
+ .HasColumnName("is_active");
+
+ b.Property("IsHot")
+ .HasColumnType("boolean")
+ .HasColumnName("is_hot");
+
+ b.Property("LegacyId")
+ .HasMaxLength(64)
+ .HasColumnType("character varying(64)")
+ .HasColumnName("legacy_id");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(200)
+ .HasColumnType("character varying(200)")
+ .HasColumnName("name");
+
+ b.Property("Pinyin")
+ .HasMaxLength(255)
+ .HasColumnType("character varying(255)")
+ .HasColumnName("pinyin");
+
+ b.Property("ShortName")
+ .HasMaxLength(100)
+ .HasColumnType("character varying(100)")
+ .HasColumnName("short_name");
+
+ b.Property("SortOrder")
+ .HasColumnType("integer")
+ .HasColumnName("sort_order");
+
+ b.Property("TenantId")
+ .HasColumnType("uuid")
+ .HasColumnName("tenant_id");
+
+ b.Property("UpdatedAt")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("updated_at")
+ .HasDefaultValueSql("now()");
+
+ b.HasKey("Id")
+ .HasName("pk_regions");
+
+ b.HasAlternateKey("TenantId", "Id")
+ .HasName("ak_regions_tenant_id_id");
+
+ b.HasIndex("TenantId", "LegacyId")
+ .IsUnique()
+ .HasDatabaseName("ix_regions_tenant_id_legacy_id");
+
+ b.ToTable("regions", (string)null);
+ });
+
+ modelBuilder.Entity("Tiku.Domain.Catalog.RegionModule", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id")
+ .HasDefaultValueSql("gen_random_uuid()");
+
+ b.Property("Color")
+ .HasMaxLength(50)
+ .HasColumnType("character varying(50)")
+ .HasColumnName("color");
+
+ b.Property("CreatedAt")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at")
+ .HasDefaultValueSql("now()");
+
+ b.Property("Description")
+ .HasColumnType("text")
+ .HasColumnName("description");
+
+ b.Property("Icon")
+ .HasMaxLength(2048)
+ .HasColumnType("character varying(2048)")
+ .HasColumnName("icon");
+
+ b.Property("IsActive")
+ .HasColumnType("boolean")
+ .HasColumnName("is_active");
+
+ b.Property("IsPrimarySchoolModule")
+ .HasColumnType("boolean")
+ .HasColumnName("is_primary_school_module");
+
+ b.Property("LegacyId")
+ .HasMaxLength(64)
+ .HasColumnType("character varying(64)")
+ .HasColumnName("legacy_id");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(200)
+ .HasColumnType("character varying(200)")
+ .HasColumnName("name");
+
+ b.Property("RegionId")
+ .HasColumnType("uuid")
+ .HasColumnName("region_id");
+
+ b.Property("Route")
+ .HasMaxLength(500)
+ .HasColumnType("character varying(500)")
+ .HasColumnName("route");
+
+ b.Property("SortOrder")
+ .HasColumnType("integer")
+ .HasColumnName("sort_order");
+
+ b.Property("TenantId")
+ .HasColumnType("uuid")
+ .HasColumnName("tenant_id");
+
+ b.Property("TextColor")
+ .HasMaxLength(50)
+ .HasColumnType("character varying(50)")
+ .HasColumnName("text_color");
+
+ b.Property("Type")
+ .HasMaxLength(50)
+ .HasColumnType("character varying(50)")
+ .HasColumnName("type");
+
+ b.Property("UpdatedAt")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("updated_at")
+ .HasDefaultValueSql("now()");
+
+ b.HasKey("Id")
+ .HasName("pk_region_modules");
+
+ b.HasAlternateKey("TenantId", "Id")
+ .HasName("ak_region_modules_tenant_id_id");
+
+ b.HasIndex("TenantId", "LegacyId")
+ .IsUnique()
+ .HasDatabaseName("ix_region_modules_tenant_id_legacy_id");
+
+ b.HasIndex("TenantId", "RegionId")
+ .HasDatabaseName("ix_region_modules_tenant_id_region_id");
+
+ b.ToTable("region_modules", (string)null);
+ });
+
+ modelBuilder.Entity("Tiku.Domain.Catalog.School", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id")
+ .HasDefaultValueSql("gen_random_uuid()");
+
+ b.Property("CreatedAt")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at")
+ .HasDefaultValueSql("now()");
+
+ b.Property("LegacyId")
+ .HasMaxLength(64)
+ .HasColumnType("character varying(64)")
+ .HasColumnName("legacy_id");
+
+ b.Property("Metadata")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("jsonb")
+ .HasColumnName("metadata")
+ .HasDefaultValueSql("'{}'::jsonb");
+
+ b.Property("ModuleId")
+ .HasColumnType("uuid")
+ .HasColumnName("module_id");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(300)
+ .HasColumnType("character varying(300)")
+ .HasColumnName("name");
+
+ b.Property("ProfessionalExamDate")
+ .HasMaxLength(255)
+ .HasColumnType("character varying(255)")
+ .HasColumnName("professional_exam_date");
+
+ b.Property("RegionId")
+ .HasColumnType("uuid")
+ .HasColumnName("region_id");
+
+ b.Property("TenantId")
+ .HasColumnType("uuid")
+ .HasColumnName("tenant_id");
+
+ b.Property("UpdatedAt")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("updated_at")
+ .HasDefaultValueSql("now()");
+
+ b.HasKey("Id")
+ .HasName("pk_schools");
+
+ b.HasAlternateKey("TenantId", "Id")
+ .HasName("ak_schools_tenant_id_id");
+
+ b.HasIndex("TenantId", "LegacyId")
+ .IsUnique()
+ .HasDatabaseName("ix_schools_tenant_id_legacy_id");
+
+ b.HasIndex("TenantId", "ModuleId")
+ .HasDatabaseName("ix_schools_tenant_id_module_id");
+
+ b.HasIndex("TenantId", "RegionId")
+ .HasDatabaseName("ix_schools_tenant_id_region_id");
+
+ b.ToTable("schools", (string)null);
+ });
+
+ modelBuilder.Entity("Tiku.Domain.Catalog.ScorelineField", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id")
+ .HasDefaultValueSql("gen_random_uuid()");
+
+ b.Property("CreatedAt")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at")
+ .HasDefaultValueSql("now()");
+
+ b.Property("Description")
+ .HasMaxLength(1000)
+ .HasColumnType("character varying(1000)")
+ .HasColumnName("description");
+
+ b.Property("FieldKey")
+ .IsRequired()
+ .HasMaxLength(64)
+ .HasColumnType("character varying(64)")
+ .HasColumnName("field_key");
+
+ b.Property("FieldName")
+ .IsRequired()
+ .HasMaxLength(200)
+ .HasColumnType("character varying(200)")
+ .HasColumnName("field_name");
+
+ b.Property("FieldType")
+ .IsRequired()
+ .HasMaxLength(32)
+ .HasColumnType("character varying(32)")
+ .HasColumnName("field_type");
+
+ b.Property("IsFilter")
+ .HasColumnType("boolean")
+ .HasColumnName("is_filter");
+
+ b.Property("IsRequired")
+ .HasColumnType("boolean")
+ .HasColumnName("is_required");
+
+ b.Property("IsTrend")
+ .HasColumnType("boolean")
+ .HasColumnName("is_trend");
+
+ b.Property("IsVisible")
+ .HasColumnType("boolean")
+ .HasColumnName("is_visible");
+
+ b.Property("LegacyId")
+ .HasMaxLength(64)
+ .HasColumnType("character varying(64)")
+ .HasColumnName("legacy_id");
+
+ b.Property("Options")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("jsonb")
+ .HasColumnName("options")
+ .HasDefaultValueSql("'[]'::jsonb");
+
+ b.Property("Placeholder")
+ .HasMaxLength(200)
+ .HasColumnType("character varying(200)")
+ .HasColumnName("placeholder");
+
+ b.Property("RegionId")
+ .HasColumnType("uuid")
+ .HasColumnName("region_id");
+
+ b.Property("SortOrder")
+ .HasColumnType("integer")
+ .HasColumnName("sort_order");
+
+ b.Property("TenantId")
+ .HasColumnType("uuid")
+ .HasColumnName("tenant_id");
+
+ b.Property("Unit")
+ .HasMaxLength(32)
+ .HasColumnType("character varying(32)")
+ .HasColumnName("unit");
+
+ b.Property("UpdatedAt")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("updated_at")
+ .HasDefaultValueSql("now()");
+
+ b.HasKey("Id")
+ .HasName("pk_scoreline_fields");
+
+ b.HasAlternateKey("TenantId", "Id")
+ .HasName("ak_scoreline_fields_tenant_id_id");
+
+ b.HasIndex("TenantId", "LegacyId")
+ .IsUnique()
+ .HasDatabaseName("ix_scoreline_fields_tenant_id_legacy_id");
+
+ b.HasIndex("TenantId", "RegionId", "FieldKey")
+ .IsUnique()
+ .HasDatabaseName("ix_scoreline_fields_tenant_id_region_id_field_key");
+
+ b.HasIndex("TenantId", "RegionId", "IsFilter", "SortOrder")
+ .HasDatabaseName("ix_scoreline_fields_tenant_id_region_id_is_filter_sort_order");
+
+ b.ToTable("scoreline_fields", (string)null);
+ });
+
+ modelBuilder.Entity("Tiku.Domain.Catalog.ScorelineRecord", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id")
+ .HasDefaultValueSql("gen_random_uuid()");
+
+ b.Property("CreatedAt")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at")
+ .HasDefaultValueSql("now()");
+
+ b.Property("FieldValues")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("jsonb")
+ .HasColumnName("field_values")
+ .HasDefaultValueSql("'{}'::jsonb");
+
+ b.Property("LegacyId")
+ .HasMaxLength(64)
+ .HasColumnType("character varying(64)")
+ .HasColumnName("legacy_id");
+
+ b.Property("MajorId")
+ .HasColumnType("uuid")
+ .HasColumnName("major_id");
+
+ b.Property("MajorName")
+ .HasMaxLength(300)
+ .HasColumnType("character varying(300)")
+ .HasColumnName("major_name");
+
+ b.Property("RegionId")
+ .HasColumnType("uuid")
+ .HasColumnName("region_id");
+
+ b.Property("SchoolId")
+ .HasColumnType("uuid")
+ .HasColumnName("school_id");
+
+ b.Property("SchoolName")
+ .HasMaxLength(300)
+ .HasColumnType("character varying(300)")
+ .HasColumnName("school_name");
+
+ b.Property("TenantId")
+ .HasColumnType("uuid")
+ .HasColumnName("tenant_id");
+
+ b.Property("UpdatedAt")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("updated_at")
+ .HasDefaultValueSql("now()");
+
+ b.Property("Year")
+ .HasColumnType("integer")
+ .HasColumnName("year");
+
+ b.HasKey("Id")
+ .HasName("pk_scoreline_records");
+
+ b.HasAlternateKey("TenantId", "Id")
+ .HasName("ak_scoreline_records_tenant_id_id");
+
+ b.HasIndex("TenantId", "LegacyId")
+ .IsUnique()
+ .HasDatabaseName("ix_scoreline_records_tenant_id_legacy_id");
+
+ b.HasIndex("TenantId", "MajorId")
+ .HasDatabaseName("ix_scoreline_records_tenant_id_major_id");
+
+ b.HasIndex("TenantId", "SchoolId")
+ .HasDatabaseName("ix_scoreline_records_tenant_id_school_id");
+
+ b.HasIndex("TenantId", "Year")
+ .HasDatabaseName("ix_scoreline_records_tenant_id_year");
+
+ b.HasIndex("TenantId", "RegionId", "SchoolId", "MajorId", "Year")
+ .HasDatabaseName("ix_scoreline_records_tenant_id_region_id_school_id_major_id_ye~");
+
+ b.HasIndex("TenantId", "Year", "SchoolName", "MajorName", "Id")
+ .IsDescending(false, true, false, false, false)
+ .HasDatabaseName("ix_scoreline_records_tenant_id_year_school_name_major_name_id");
+
+ b.ToTable("scoreline_records", (string)null);
+ });
+
+ modelBuilder.Entity("Tiku.Domain.Catalog.Subject", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id")
+ .HasDefaultValueSql("gen_random_uuid()");
+
+ b.Property("CreatedAt")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at")
+ .HasDefaultValueSql("now()");
+
+ b.Property("Description")
+ .HasColumnType("text")
+ .HasColumnName("description");
+
+ b.Property("Icon")
+ .HasMaxLength(2048)
+ .HasColumnType("character varying(2048)")
+ .HasColumnName("icon");
+
+ b.Property("IsActive")
+ .HasColumnType("boolean")
+ .HasColumnName("is_active");
+
+ b.Property("LegacyId")
+ .HasMaxLength(64)
+ .HasColumnType("character varying(64)")
+ .HasColumnName("legacy_id");
+
+ b.Property("MajorId")
+ .HasColumnType("uuid")
+ .HasColumnName("major_id");
+
+ b.Property("MajorLegacyIds")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("jsonb")
+ .HasColumnName("major_legacy_ids")
+ .HasDefaultValueSql("'[]'::jsonb");
+
+ b.Property("ModuleId")
+ .HasColumnType("uuid")
+ .HasColumnName("module_id");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(300)
+ .HasColumnType("character varying(300)")
+ .HasColumnName("name");
+
+ b.Property("NodeId")
+ .HasColumnType("uuid")
+ .HasColumnName("node_id");
+
+ b.Property("RegionId")
+ .HasColumnType("uuid")
+ .HasColumnName("region_id");
+
+ b.Property