forked from xiongyuxing/tiku-backend.net
feat(saas): implement marketplace and tenant onboarding
This commit is contained in:
@@ -25,6 +25,11 @@ public interface IAssetManagementService
|
||||
AssetUploadConfirmCommand command,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<ContentManagementResult<ContentAssetManagementItem>> ArchiveAssetAsync(
|
||||
AssetManagementActor actor,
|
||||
Guid assetId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<AssetManagementSignedAccessResult> SignDownloadAsync(
|
||||
AssetManagementActor actor,
|
||||
AssetAccessSignCommand command,
|
||||
|
||||
@@ -36,14 +36,16 @@ public sealed record BackofficeBootstrap(
|
||||
|
||||
public sealed record BackofficeUiBootstrap(
|
||||
IReadOnlyCollection<string> PermissionCodes,
|
||||
IReadOnlyCollection<BackofficeMenuItem> Menus);
|
||||
IReadOnlyCollection<BackofficeMenuItem> Menus,
|
||||
IReadOnlyCollection<string> EnabledFeatures,
|
||||
IReadOnlyCollection<FeatureQuotaSnapshot> Quotas);
|
||||
|
||||
public sealed record BackofficePermissionItem(
|
||||
Guid Id,
|
||||
string Code,
|
||||
string Name,
|
||||
BackendPermissionArea Area,
|
||||
string Module,
|
||||
string PermissionModuleCode,
|
||||
string? Description,
|
||||
int SortOrder);
|
||||
|
||||
|
||||
@@ -58,12 +58,12 @@ public sealed record PlatformTenantDomainItem(
|
||||
public sealed record PlatformTenantSubscriptionItem(
|
||||
Guid Id,
|
||||
Guid TenantId,
|
||||
string PlanCode,
|
||||
TenantSubscriptionStatus Status,
|
||||
DateTimeOffset? StartsAt,
|
||||
DateTimeOffset? ExpiresAt,
|
||||
string? BillingCycle,
|
||||
int AmountCents,
|
||||
Guid BaseOfferingVersionId,
|
||||
TenantSaasSubscriptionStatus Status,
|
||||
DateTimeOffset StartsAt,
|
||||
DateTimeOffset CurrentPeriodStart,
|
||||
DateTimeOffset CurrentPeriodEnd,
|
||||
bool CancelAtPeriodEnd,
|
||||
JsonElement Metadata);
|
||||
|
||||
public sealed record TenantBillingProfileItem(
|
||||
@@ -75,7 +75,7 @@ public sealed record TenantBillingProfileItem(
|
||||
string? ContactEmail,
|
||||
string? BillingAddress,
|
||||
string? InvoiceTitle,
|
||||
TenantInvoiceTitleType? InvoiceType,
|
||||
TenantBillingInvoiceTitleType? InvoiceType,
|
||||
string? BankName,
|
||||
string? BankAccountMasked,
|
||||
JsonElement Metadata);
|
||||
@@ -86,7 +86,17 @@ public sealed record CreatePlatformTenantCommand(
|
||||
string? LegalName,
|
||||
TenantStatus Status,
|
||||
BillingStatus BillingStatus,
|
||||
JsonElement Metadata);
|
||||
JsonElement Metadata,
|
||||
string? OwnerEmail,
|
||||
string? OwnerPhone,
|
||||
string OwnerName,
|
||||
string TemporaryPassword);
|
||||
|
||||
public sealed record PlatformTenantProvisioningResult(
|
||||
PlatformTenantItem Tenant,
|
||||
Guid OwnerUserId,
|
||||
string OwnerIdentifier,
|
||||
bool MustChangePassword);
|
||||
|
||||
public sealed record UpdatePlatformTenantStatusCommand(
|
||||
Guid TenantId,
|
||||
@@ -103,45 +113,11 @@ public sealed record UpsertPlatformTenantBillingProfileCommand(
|
||||
string? ContactEmail,
|
||||
string? BillingAddress,
|
||||
string? InvoiceTitle,
|
||||
TenantInvoiceTitleType? InvoiceType,
|
||||
TenantBillingInvoiceTitleType? InvoiceType,
|
||||
string? BankName,
|
||||
string? BankAccountMasked,
|
||||
JsonElement Metadata);
|
||||
|
||||
public sealed record PlatformPlanList(IReadOnlyCollection<PlatformSaasPlan> Items);
|
||||
|
||||
public sealed record PlatformPlanModuleEntitlements(
|
||||
string PlanCode,
|
||||
IReadOnlyCollection<string> ModuleCodes);
|
||||
|
||||
public sealed record ReplacePlatformPlanModulesCommand(
|
||||
string PlanCode,
|
||||
IReadOnlyCollection<string> ModuleCodes);
|
||||
|
||||
public sealed record PlatformTenantModuleOverrideItem(
|
||||
Guid TenantId,
|
||||
string ModuleCode,
|
||||
TenantModuleOverrideMode Mode,
|
||||
DateTimeOffset? ExpiresAt,
|
||||
string? Reason);
|
||||
|
||||
public sealed record UpsertPlatformTenantModuleOverrideCommand(
|
||||
Guid TenantId,
|
||||
string ModuleCode,
|
||||
TenantModuleOverrideMode Mode,
|
||||
DateTimeOffset? ExpiresAt,
|
||||
string Reason);
|
||||
|
||||
public sealed record UpsertPlatformSubscriptionCommand(
|
||||
Guid TenantId,
|
||||
string PlanCode,
|
||||
TenantSubscriptionStatus Status,
|
||||
DateTimeOffset? StartsAt,
|
||||
DateTimeOffset? ExpiresAt,
|
||||
string? BillingCycle,
|
||||
int AmountCents,
|
||||
JsonElement Metadata);
|
||||
|
||||
public sealed record PlatformDomainList(IReadOnlyCollection<PlatformTenantDomainItem> Items);
|
||||
|
||||
public sealed record PlatformDomainRecheckResult(Guid DomainId, TenantDomainStatus Status, DateTimeOffset LastCheckedAt);
|
||||
@@ -189,15 +165,15 @@ public sealed record UpdatePlatformAuditAlertStatusCommand(
|
||||
PlatformAuditAlertStatus Status,
|
||||
string? ResolutionNote);
|
||||
|
||||
public sealed record PlatformDunningChannelList(IReadOnlyCollection<PlatformDunningChannelItem> Items);
|
||||
public sealed record PlatformBillingDunningChannelList(IReadOnlyCollection<PlatformBillingDunningChannelItem> Items);
|
||||
|
||||
public sealed record PlatformDunningChannelItem(
|
||||
public sealed record PlatformBillingDunningChannelItem(
|
||||
Guid Id,
|
||||
string ChannelCode,
|
||||
string Name,
|
||||
string? Description,
|
||||
bool Enabled,
|
||||
PlatformDunningProvider Provider,
|
||||
PlatformBillingDunningProvider Provider,
|
||||
string WebhookUrlMasked,
|
||||
string? SecretRef,
|
||||
IReadOnlyCollection<string> ReminderTypes,
|
||||
@@ -209,13 +185,13 @@ public sealed record PlatformDunningChannelItem(
|
||||
DateTimeOffset CreatedAt,
|
||||
DateTimeOffset UpdatedAt);
|
||||
|
||||
public sealed record UpsertPlatformDunningChannelCommand(
|
||||
public sealed record UpsertPlatformBillingDunningChannelCommand(
|
||||
Guid? ChannelId,
|
||||
string ChannelCode,
|
||||
string Name,
|
||||
string? Description,
|
||||
bool Enabled,
|
||||
PlatformDunningProvider Provider,
|
||||
PlatformBillingDunningProvider Provider,
|
||||
string WebhookUrl,
|
||||
string? SecretRef,
|
||||
IReadOnlyCollection<string> ReminderTypes,
|
||||
@@ -225,18 +201,18 @@ public sealed record UpsertPlatformDunningChannelCommand(
|
||||
int TimeoutSeconds,
|
||||
JsonElement Metadata);
|
||||
|
||||
public sealed record DisablePlatformDunningChannelCommand(Guid ChannelId, string? Reason);
|
||||
public sealed record DisablePlatformBillingDunningChannelCommand(Guid ChannelId, string? Reason);
|
||||
|
||||
public sealed record PlatformDunningEventList(IReadOnlyCollection<PlatformDunningEventItem> Items);
|
||||
public sealed record PlatformBillingDunningEventList(IReadOnlyCollection<PlatformBillingDunningEventItem> Items);
|
||||
|
||||
public sealed record PlatformDunningEventItem(
|
||||
public sealed record PlatformBillingDunningEventItem(
|
||||
Guid Id,
|
||||
Guid TenantId,
|
||||
Guid ChannelId,
|
||||
Guid ReminderId,
|
||||
Guid InvoiceId,
|
||||
PlatformDunningProvider Provider,
|
||||
PlatformDunningNotificationStatus Status,
|
||||
PlatformBillingDunningProvider Provider,
|
||||
PlatformBillingDunningNotificationStatus Status,
|
||||
int Attempts,
|
||||
DateTimeOffset ScheduledAt,
|
||||
DateTimeOffset? NextAttemptAt,
|
||||
@@ -250,20 +226,16 @@ public sealed record PlatformDunningEventItem(
|
||||
DateTimeOffset CreatedAt,
|
||||
DateTimeOffset UpdatedAt);
|
||||
|
||||
public sealed record RetryPlatformDunningEventCommand(Guid EventId, string? Reason);
|
||||
public sealed record RetryPlatformBillingDunningEventCommand(Guid EventId, string? Reason);
|
||||
|
||||
public interface IPlatformAdminService
|
||||
{
|
||||
Task<PlatformOverview> GetOverviewAsync(PlatformAdminActor actor, CancellationToken cancellationToken = default);
|
||||
Task<PlatformTenantList> GetTenantsAsync(PlatformAdminActor actor, PlatformAdminQuery query, CancellationToken cancellationToken = default);
|
||||
Task<PlatformTenantDetail> GetTenantDetailAsync(PlatformAdminActor actor, Guid tenantId, CancellationToken cancellationToken = default);
|
||||
Task<PlatformTenantItem> CreateTenantAsync(PlatformAdminActor actor, CreatePlatformTenantCommand command, CancellationToken cancellationToken = default);
|
||||
Task<PlatformTenantProvisioningResult> CreateTenantAsync(PlatformAdminActor actor, CreatePlatformTenantCommand command, CancellationToken cancellationToken = default);
|
||||
Task<PlatformTenantItem> UpdateTenantStatusAsync(PlatformAdminActor actor, UpdatePlatformTenantStatusCommand command, CancellationToken cancellationToken = default);
|
||||
Task<TenantBillingProfileItem> UpsertTenantBillingProfileAsync(PlatformAdminActor actor, UpsertPlatformTenantBillingProfileCommand command, CancellationToken cancellationToken = default);
|
||||
Task<PlatformPlanList> GetPlansAsync(PlatformAdminActor actor, PlatformAdminQuery query, CancellationToken cancellationToken = default);
|
||||
Task<PlatformPlanModuleEntitlements> ReplacePlanModulesAsync(PlatformAdminActor actor, ReplacePlatformPlanModulesCommand command, CancellationToken cancellationToken = default);
|
||||
Task<PlatformTenantSubscriptionItem> UpsertSubscriptionAsync(PlatformAdminActor actor, UpsertPlatformSubscriptionCommand command, CancellationToken cancellationToken = default);
|
||||
Task<PlatformTenantModuleOverrideItem> UpsertTenantModuleOverrideAsync(PlatformAdminActor actor, UpsertPlatformTenantModuleOverrideCommand command, CancellationToken cancellationToken = default);
|
||||
Task<PlatformDomainList> GetDomainsAsync(PlatformAdminActor actor, PlatformAdminQuery query, CancellationToken cancellationToken = default);
|
||||
Task<PlatformDomainRecheckResult> RecheckDomainAsync(PlatformAdminActor actor, Guid domainId, CancellationToken cancellationToken = default);
|
||||
Task<PlatformStaffList> GetStaffAsync(PlatformAdminActor actor, PlatformAdminQuery query, CancellationToken cancellationToken = default);
|
||||
@@ -272,12 +244,12 @@ public interface IPlatformAdminService
|
||||
Task<PlatformAuditLogList> GetAuditLogsAsync(PlatformAdminActor actor, PlatformAdminQuery query, CancellationToken cancellationToken = default);
|
||||
Task<PlatformAuditAlertList> GetAuditAlertsAsync(PlatformAdminActor actor, PlatformAdminQuery query, CancellationToken cancellationToken = default);
|
||||
Task<PlatformAuditAlert> UpdateAuditAlertStatusAsync(PlatformAdminActor actor, UpdatePlatformAuditAlertStatusCommand command, CancellationToken cancellationToken = default);
|
||||
Task<PlatformDunningChannelList> GetDunningChannelsAsync(PlatformAdminActor actor, PlatformAdminQuery query, CancellationToken cancellationToken = default);
|
||||
Task<PlatformDunningChannelItem> UpsertDunningChannelAsync(PlatformAdminActor actor, UpsertPlatformDunningChannelCommand command, CancellationToken cancellationToken = default);
|
||||
Task<PlatformDunningChannelItem> DisableDunningChannelAsync(PlatformAdminActor actor, DisablePlatformDunningChannelCommand command, CancellationToken cancellationToken = default);
|
||||
Task<PlatformDunningEventList> GetDunningEventsAsync(PlatformAdminActor actor, PlatformAdminQuery query, CancellationToken cancellationToken = default);
|
||||
Task<PlatformDunningEventItem> GetDunningEventDetailAsync(PlatformAdminActor actor, Guid eventId, CancellationToken cancellationToken = default);
|
||||
Task<PlatformDunningEventItem> RetryDunningEventAsync(PlatformAdminActor actor, RetryPlatformDunningEventCommand command, CancellationToken cancellationToken = default);
|
||||
Task<PlatformBillingDunningChannelList> GetBillingDunningChannelsAsync(PlatformAdminActor actor, PlatformAdminQuery query, CancellationToken cancellationToken = default);
|
||||
Task<PlatformBillingDunningChannelItem> UpsertBillingDunningChannelAsync(PlatformAdminActor actor, UpsertPlatformBillingDunningChannelCommand command, CancellationToken cancellationToken = default);
|
||||
Task<PlatformBillingDunningChannelItem> DisableBillingDunningChannelAsync(PlatformAdminActor actor, DisablePlatformBillingDunningChannelCommand command, CancellationToken cancellationToken = default);
|
||||
Task<PlatformBillingDunningEventList> GetBillingDunningEventsAsync(PlatformAdminActor actor, PlatformAdminQuery query, CancellationToken cancellationToken = default);
|
||||
Task<PlatformBillingDunningEventItem> GetBillingDunningEventDetailAsync(PlatformAdminActor actor, Guid eventId, CancellationToken cancellationToken = default);
|
||||
Task<PlatformBillingDunningEventItem> RetryBillingDunningEventAsync(PlatformAdminActor actor, RetryPlatformBillingDunningEventCommand command, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
public sealed class PlatformAdminException(string message, string code) : Exception(message)
|
||||
|
||||
242
Tiku.Application/PlatformBilling/PlatformBillingContracts.cs
Normal file
242
Tiku.Application/PlatformBilling/PlatformBillingContracts.cs
Normal file
@@ -0,0 +1,242 @@
|
||||
using System.Text.Json;
|
||||
using Tiku.Application.Commerce;
|
||||
using Tiku.Application.Security;
|
||||
using Tiku.Domain.Platform;
|
||||
|
||||
namespace Tiku.Application.PlatformBilling;
|
||||
|
||||
public sealed record SaasCatalogActor(Guid UserId);
|
||||
public sealed record TenantBillingActor(Guid UserId, Guid TenantId);
|
||||
|
||||
public sealed record UpsertSaasFeatureCommand(
|
||||
Guid? Id,
|
||||
string Code,
|
||||
string Name,
|
||||
string Category,
|
||||
string? Description,
|
||||
int ReferencePriceCents,
|
||||
string Currency,
|
||||
SaasFeatureStatus Status,
|
||||
int SortOrder);
|
||||
|
||||
public sealed record UpsertSaasOfferingCommand(
|
||||
Guid? Id,
|
||||
string Code,
|
||||
string Name,
|
||||
SaasOfferingType Type,
|
||||
SaasOfferingStatus Status,
|
||||
string? Description,
|
||||
int SortOrder);
|
||||
|
||||
public sealed record UpsertSaasFeatureLimitCommand(
|
||||
Guid? Id,
|
||||
string MetricCode,
|
||||
string FeatureCode,
|
||||
string Name,
|
||||
string Unit,
|
||||
SaasFeatureLimitKind Kind,
|
||||
int WarningPercent,
|
||||
bool IsHardLimit);
|
||||
|
||||
public sealed record UpsertSaasOfferingVersionCommand(
|
||||
Guid? Id,
|
||||
Guid OfferingId,
|
||||
PlatformBillingCycle BillingCycle,
|
||||
int OriginalAmountCents,
|
||||
int AmountCents,
|
||||
string Currency,
|
||||
DateTimeOffset? EffectiveAt,
|
||||
IReadOnlyCollection<string> FeatureCodes,
|
||||
IReadOnlyDictionary<string, long> Limits,
|
||||
JsonElement Metadata);
|
||||
|
||||
public sealed record SaasOfferingVersionItem(
|
||||
Guid Id,
|
||||
Guid OfferingId,
|
||||
string OfferingCode,
|
||||
string OfferingName,
|
||||
SaasOfferingType OfferingType,
|
||||
int Version,
|
||||
SaasOfferingVersionStatus Status,
|
||||
PlatformBillingCycle BillingCycle,
|
||||
int OriginalAmountCents,
|
||||
int AmountCents,
|
||||
string Currency,
|
||||
DateTimeOffset? EffectiveAt,
|
||||
DateTimeOffset? PublishedAt,
|
||||
IReadOnlyCollection<string> FeatureCodes,
|
||||
IReadOnlyDictionary<string, long> Limits);
|
||||
|
||||
public sealed record SaasCatalogSnapshot(
|
||||
IReadOnlyCollection<SaasFeature> Features,
|
||||
IReadOnlyCollection<SaasFeatureLimitDefinition> LimitDefinitions,
|
||||
IReadOnlyCollection<SaasOffering> Offerings,
|
||||
IReadOnlyCollection<SaasOfferingVersionItem> Versions);
|
||||
|
||||
public interface ISaasCatalogAdminService
|
||||
{
|
||||
Task<SaasCatalogSnapshot> GetCatalogAsync(SaasCatalogActor actor, CancellationToken cancellationToken = default);
|
||||
Task<SaasFeature> UpsertFeatureAsync(SaasCatalogActor actor, UpsertSaasFeatureCommand command, CancellationToken cancellationToken = default);
|
||||
Task<SaasFeatureLimitDefinition> UpsertLimitDefinitionAsync(SaasCatalogActor actor, UpsertSaasFeatureLimitCommand command, CancellationToken cancellationToken = default);
|
||||
Task<SaasOffering> UpsertOfferingAsync(SaasCatalogActor actor, UpsertSaasOfferingCommand command, CancellationToken cancellationToken = default);
|
||||
Task<SaasOfferingVersionItem> UpsertDraftVersionAsync(SaasCatalogActor actor, UpsertSaasOfferingVersionCommand command, CancellationToken cancellationToken = default);
|
||||
Task<SaasOfferingVersionItem> PublishVersionAsync(SaasCatalogActor actor, Guid versionId, CancellationToken cancellationToken = default);
|
||||
Task<SaasOfferingVersionItem> CloneVersionAsync(SaasCatalogActor actor, Guid versionId, CancellationToken cancellationToken = default);
|
||||
Task<SaasOfferingVersionItem> RetireVersionAsync(SaasCatalogActor actor, Guid versionId, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
public sealed record TenantBillingCatalog(
|
||||
IReadOnlyCollection<SaasFeature> Features,
|
||||
IReadOnlyCollection<SaasOfferingVersionItem> BasePlans,
|
||||
IReadOnlyCollection<SaasOfferingVersionItem> AddOns);
|
||||
|
||||
public sealed record CreatePlatformBillingQuoteCommand(
|
||||
Guid BaseOfferingVersionId,
|
||||
IReadOnlyCollection<Guid> AddOnOfferingVersionIds,
|
||||
PlatformBillingOrderPurpose Purpose,
|
||||
string IdempotencyKey);
|
||||
|
||||
public sealed record PlatformBillingQuoteItemView(
|
||||
Guid OfferingVersionId,
|
||||
string OfferingCode,
|
||||
string OfferingName,
|
||||
PlatformBillingItemType ItemType,
|
||||
int UnitAmountCents,
|
||||
int AmountCents);
|
||||
|
||||
public sealed record PlatformBillingQuoteView(
|
||||
Guid Id,
|
||||
string QuoteNo,
|
||||
PlatformBillingQuoteStatus Status,
|
||||
PlatformBillingOrderPurpose Purpose,
|
||||
int OriginalAmountCents,
|
||||
int DiscountAmountCents,
|
||||
int TotalAmountCents,
|
||||
string Currency,
|
||||
DateTimeOffset ExpiresAt,
|
||||
IReadOnlyCollection<PlatformBillingQuoteItemView> Items,
|
||||
IReadOnlyCollection<string> FeatureCodes,
|
||||
IReadOnlyDictionary<string, long> Limits);
|
||||
|
||||
public sealed record CreatePlatformBillingOrderCommand(Guid QuoteId, string IdempotencyKey);
|
||||
public sealed record CreatePlatformBillingPaymentCommand(
|
||||
string OrderNo,
|
||||
string Provider,
|
||||
string Method,
|
||||
string IdempotencyKey,
|
||||
string? OpenId,
|
||||
string? ReturnUrl,
|
||||
string? QuitUrl);
|
||||
|
||||
public sealed record PlatformBillingOrderView(
|
||||
Guid Id,
|
||||
string OrderNo,
|
||||
PlatformBillingOrderPurpose Purpose,
|
||||
PlatformBillingOrderStatus Status,
|
||||
int TotalAmountCents,
|
||||
string Currency,
|
||||
DateTimeOffset ExpiresAt,
|
||||
DateTimeOffset? PaidAt,
|
||||
IReadOnlyCollection<PlatformBillingQuoteItemView> Items);
|
||||
|
||||
public sealed record PlatformBillingPaymentView(
|
||||
Guid Id,
|
||||
string PaymentNo,
|
||||
string Provider,
|
||||
string Method,
|
||||
PlatformBillingPaymentStatus Status,
|
||||
int AmountCents,
|
||||
JsonElement ClientPayload);
|
||||
|
||||
public sealed record TenantSubscriptionView(
|
||||
Guid Id,
|
||||
TenantSaasSubscriptionStatus Status,
|
||||
DateTimeOffset StartsAt,
|
||||
DateTimeOffset CurrentPeriodStart,
|
||||
DateTimeOffset CurrentPeriodEnd,
|
||||
bool CancelAtPeriodEnd,
|
||||
Guid BaseOfferingVersionId,
|
||||
Guid? ScheduledBaseOfferingVersionId,
|
||||
IReadOnlyCollection<string> FeatureCodes);
|
||||
|
||||
public sealed record ChangeTenantSubscriptionCommand(Guid BaseOfferingVersionId, IReadOnlyCollection<Guid> AddOnOfferingVersionIds);
|
||||
|
||||
public interface ITenantBillingService
|
||||
{
|
||||
Task<TenantBillingCatalog> GetCatalogAsync(TenantBillingActor actor, CancellationToken cancellationToken = default);
|
||||
Task<PlatformBillingQuoteView> CreateQuoteAsync(TenantBillingActor actor, CreatePlatformBillingQuoteCommand command, CancellationToken cancellationToken = default);
|
||||
Task<PlatformBillingOrderView> CreateOrderAsync(TenantBillingActor actor, CreatePlatformBillingOrderCommand command, CancellationToken cancellationToken = default);
|
||||
Task<PlatformBillingPaymentView> CreatePaymentAsync(TenantBillingActor actor, CreatePlatformBillingPaymentCommand command, CancellationToken cancellationToken = default);
|
||||
Task<IReadOnlyCollection<PlatformBillingOrderView>> GetOrdersAsync(TenantBillingActor actor, int limit, CancellationToken cancellationToken = default);
|
||||
Task<PlatformBillingOrderView> GetOrderAsync(TenantBillingActor actor, string orderNo, CancellationToken cancellationToken = default);
|
||||
Task<TenantSubscriptionView?> GetSubscriptionAsync(TenantBillingActor actor, CancellationToken cancellationToken = default);
|
||||
Task<PlatformBillingOrderView> ChangeSubscriptionAsync(TenantBillingActor actor, ChangeTenantSubscriptionCommand command, string idempotencyKey, CancellationToken cancellationToken = default);
|
||||
Task<PlatformBillingOrderView> RenewSubscriptionAsync(TenantBillingActor actor, string idempotencyKey, CancellationToken cancellationToken = default);
|
||||
Task<TenantSubscriptionView> CancelSubscriptionAsync(TenantBillingActor actor, CancellationToken cancellationToken = default);
|
||||
Task<IReadOnlyCollection<FeatureQuotaSnapshot>> GetUsageAsync(TenantBillingActor actor, CancellationToken cancellationToken = default);
|
||||
Task<IReadOnlyCollection<PlatformBillingInvoice>> GetInvoicesAsync(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 interface IPlatformBillingAdminService
|
||||
{
|
||||
Task<IReadOnlyCollection<PlatformBillingOrder>> GetOrdersAsync(SaasCatalogActor actor, PlatformBillingAdminQuery query, CancellationToken cancellationToken = default);
|
||||
Task<IReadOnlyCollection<PlatformBillingPayment>> GetPaymentsAsync(SaasCatalogActor actor, PlatformBillingAdminQuery query, CancellationToken cancellationToken = default);
|
||||
Task<IReadOnlyCollection<PlatformBillingRefund>> GetRefundsAsync(SaasCatalogActor actor, PlatformBillingAdminQuery query, CancellationToken cancellationToken = default);
|
||||
Task<IReadOnlyCollection<PlatformBillingInvoice>> GetInvoicesAsync(SaasCatalogActor actor, PlatformBillingAdminQuery query, CancellationToken cancellationToken = default);
|
||||
Task<IReadOnlyCollection<TenantSaasSubscription>> GetSubscriptionsAsync(SaasCatalogActor actor, PlatformBillingAdminQuery query, CancellationToken cancellationToken = default);
|
||||
Task<PlatformBillingPayment> ConfirmManualPaymentAsync(SaasCatalogActor actor, ConfirmManualPaymentCommand command, CancellationToken cancellationToken = default);
|
||||
Task<TenantFeatureOverride> UpsertFeatureOverrideAsync(SaasCatalogActor actor, UpsertTenantFeatureOverrideCommand command, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
public interface IPlatformBillingPaymentGateway
|
||||
{
|
||||
Task<CreatePaymentProviderResult> CreatePaymentAsync(string provider, CreatePaymentProviderRequest request, CancellationToken cancellationToken = default);
|
||||
Task<PaymentNotificationResult> ParseNotificationAsync(string provider, PaymentNotificationRequest request, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
public sealed record PlatformBillingNotification(
|
||||
string Provider,
|
||||
IReadOnlyDictionary<string, string> Headers,
|
||||
string RawBody,
|
||||
JsonElement Body);
|
||||
|
||||
public interface IPlatformBillingNotificationService
|
||||
{
|
||||
Task ProcessAsync(PlatformBillingNotification notification, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
public interface IPlatformBillingSettlementService
|
||||
{
|
||||
Task<PlatformBillingPayment> MarkPaidAsync(
|
||||
Guid paymentId,
|
||||
string providerEventId,
|
||||
string eventType,
|
||||
string? providerTradeNo,
|
||||
DateTimeOffset paidAt,
|
||||
JsonElement payload,
|
||||
Guid? actorUserId,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
public sealed class SaasSubscriptionLifecycleOptions
|
||||
{
|
||||
public bool Enabled { get; set; } = true;
|
||||
public int BatchSize { get; set; } = 100;
|
||||
public int PastDueGraceDays { get; set; } = 7;
|
||||
}
|
||||
|
||||
public interface ISaasSubscriptionLifecycleService
|
||||
{
|
||||
Task<int> ProcessDueAsync(
|
||||
DateTimeOffset? asOf = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
public sealed class PlatformBillingException(string message, string code) : Exception(message)
|
||||
{
|
||||
public string Code { get; } = code;
|
||||
}
|
||||
@@ -6,13 +6,19 @@ public static class BackendPermissions
|
||||
public const string TenantStaffManage = "tenant:staff:manage";
|
||||
public const string TenantRoleManage = "tenant:role:manage";
|
||||
public const string TenantStudentManage = "tenant:student:manage";
|
||||
public const string TenantContentManage = "tenant:content:manage";
|
||||
public const string TenantContentManage = "tenant:question-bank:manage";
|
||||
public const string TenantVocabularyManage = "tenant:vocabulary:manage";
|
||||
public const string TenantHandbookManage = "tenant:handbook:manage";
|
||||
public const string TenantVideoManage = "tenant:video:manage";
|
||||
public const string TenantScorelineManage = "tenant:scoreline:manage";
|
||||
public const string TenantSiteContentManage = "tenant:site-content:manage";
|
||||
public const string TenantSettingsManage = "tenant:settings:manage";
|
||||
public const string TenantProviderManage = "tenant:provider:manage";
|
||||
public const string TenantCommerceOperate = "tenant:commerce:operate";
|
||||
public const string TenantCrmManage = "tenant:crm:manage";
|
||||
public const string TenantCommissionManage = "tenant:commission:manage";
|
||||
public const string TenantJobManage = "tenant:job:manage";
|
||||
public const string TenantBillingManage = "tenant:billing:manage";
|
||||
|
||||
public const string PlatformDashboardView = "platform:dashboard:view";
|
||||
public const string PlatformTenantManage = "platform:tenant:manage";
|
||||
@@ -21,6 +27,8 @@ public static class BackendPermissions
|
||||
public const string PlatformQuestionBankManage = "platform:question-bank:manage";
|
||||
public const string PlatformAuditView = "platform:audit:view";
|
||||
public const string PlatformBillingNotification = "platform:billing:notification";
|
||||
public const string PlatformSaasCatalogManage = "platform:saas-catalog:manage";
|
||||
public const string PlatformSaasBillingManage = "platform:saas-billing:manage";
|
||||
|
||||
public static readonly IReadOnlySet<string> Tenant = new HashSet<string>(StringComparer.Ordinal)
|
||||
{
|
||||
@@ -29,12 +37,18 @@ public static class BackendPermissions
|
||||
TenantRoleManage,
|
||||
TenantStudentManage,
|
||||
TenantContentManage,
|
||||
TenantVocabularyManage,
|
||||
TenantHandbookManage,
|
||||
TenantVideoManage,
|
||||
TenantScorelineManage,
|
||||
TenantSiteContentManage,
|
||||
TenantSettingsManage,
|
||||
TenantProviderManage,
|
||||
TenantCommerceOperate,
|
||||
TenantCrmManage,
|
||||
TenantCommissionManage,
|
||||
TenantJobManage
|
||||
TenantJobManage,
|
||||
TenantBillingManage
|
||||
};
|
||||
|
||||
public static readonly IReadOnlySet<string> Platform = new HashSet<string>(StringComparer.Ordinal)
|
||||
@@ -45,7 +59,9 @@ public static class BackendPermissions
|
||||
PlatformRoleManage,
|
||||
PlatformQuestionBankManage,
|
||||
PlatformAuditView,
|
||||
PlatformBillingNotification
|
||||
PlatformBillingNotification,
|
||||
PlatformSaasCatalogManage,
|
||||
PlatformSaasBillingManage
|
||||
};
|
||||
|
||||
public static void EnsureTenant(string permissionCode)
|
||||
|
||||
7
Tiku.Application/Security/CapabilityOperation.cs
Normal file
7
Tiku.Application/Security/CapabilityOperation.cs
Normal file
@@ -0,0 +1,7 @@
|
||||
namespace Tiku.Application.Security;
|
||||
|
||||
public enum CapabilityOperation
|
||||
{
|
||||
Read,
|
||||
Write
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
namespace Tiku.Application.Security;
|
||||
|
||||
public static class FeatureQuotaConsumptionExtensions
|
||||
{
|
||||
public static async Task<bool> ConsumeQuotaIfConfiguredAsync(
|
||||
this IFeatureAccessService featureAccessService,
|
||||
Guid tenantId,
|
||||
string metricCode,
|
||||
long amount = 1,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var configured = (await featureAccessService.GetQuotaSummaryAsync(tenantId, cancellationToken))
|
||||
.Any(item => string.Equals(item.MetricCode, metricCode, StringComparison.Ordinal));
|
||||
if (!configured)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!await featureAccessService.TryConsumeQuotaAsync(tenantId, metricCode, amount, cancellationToken))
|
||||
{
|
||||
throw new FeatureAccessException("The tenant feature quota has been exhausted.", "feature_quota_exhausted");
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
namespace Tiku.Application.Security;
|
||||
|
||||
public enum CapabilityOperation
|
||||
{
|
||||
Read,
|
||||
Write
|
||||
}
|
||||
|
||||
public interface ICapabilityAccessEvaluator
|
||||
{
|
||||
Task<bool> IsAllowedAsync(
|
||||
Guid tenantId,
|
||||
string moduleCode,
|
||||
CapabilityOperation operation,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<IReadOnlySet<string>> GetEnabledModulesAsync(
|
||||
Guid tenantId,
|
||||
CapabilityOperation operation = CapabilityOperation.Read,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
64
Tiku.Application/Security/IFeatureAccessService.cs
Normal file
64
Tiku.Application/Security/IFeatureAccessService.cs
Normal file
@@ -0,0 +1,64 @@
|
||||
namespace Tiku.Application.Security;
|
||||
|
||||
public enum FeatureAccessOperation
|
||||
{
|
||||
Read,
|
||||
Write
|
||||
}
|
||||
|
||||
public sealed record FeatureAccessDecision(
|
||||
bool Allowed,
|
||||
string? DenialCode,
|
||||
string FeatureCode,
|
||||
FeatureAccessOperation Operation);
|
||||
|
||||
public sealed record FeatureQuotaSnapshot(
|
||||
string MetricCode,
|
||||
long UsedValue,
|
||||
long LimitValue,
|
||||
int UsedPercent,
|
||||
bool Warning,
|
||||
bool Exceeded,
|
||||
DateTimeOffset PeriodStart,
|
||||
DateTimeOffset PeriodEnd);
|
||||
|
||||
public interface IFeatureAccessService
|
||||
{
|
||||
Task<FeatureAccessDecision> EvaluateAsync(
|
||||
Guid tenantId,
|
||||
string featureCode,
|
||||
FeatureAccessOperation operation,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<IReadOnlySet<string>> GetEnabledFeaturesAsync(
|
||||
Guid tenantId,
|
||||
FeatureAccessOperation operation = FeatureAccessOperation.Read,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<IReadOnlySet<string>> FilterPermissionCodesAsync(
|
||||
Guid tenantId,
|
||||
IEnumerable<string> permissionCodes,
|
||||
FeatureAccessOperation operation = FeatureAccessOperation.Read,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<IReadOnlyCollection<FeatureQuotaSnapshot>> GetQuotaSummaryAsync(
|
||||
Guid tenantId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<bool> TryConsumeQuotaAsync(
|
||||
Guid tenantId,
|
||||
string metricCode,
|
||||
long amount,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task ReleaseQuotaAsync(
|
||||
Guid tenantId,
|
||||
string metricCode,
|
||||
long amount,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
public sealed class FeatureAccessException(string message, string code) : Exception(message)
|
||||
{
|
||||
public string Code { get; } = code;
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
namespace Tiku.Application.Security;
|
||||
|
||||
public sealed record ReconcileFeatureUsageRequest(
|
||||
Guid TenantId,
|
||||
SystemScopeCallerType CallerType,
|
||||
string Caller,
|
||||
string Reason,
|
||||
string CorrelationId);
|
||||
|
||||
public sealed record ReconciledFeatureUsage(
|
||||
string MetricCode,
|
||||
long ActualValue,
|
||||
long LimitValue,
|
||||
bool Warning,
|
||||
bool Exceeded);
|
||||
|
||||
public interface IFeatureUsageReconciliationService
|
||||
{
|
||||
Task<IReadOnlyCollection<ReconciledFeatureUsage>> ReconcileTenantAsync(
|
||||
ReconcileFeatureUsageRequest request,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<int> ProcessDueAsync(CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
public sealed class FeatureUsageReconciliationOptions
|
||||
{
|
||||
public bool Enabled { get; set; } = true;
|
||||
public int BatchSize { get; set; } = 100;
|
||||
public int IntervalMinutes { get; set; } = 60;
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
namespace Tiku.Application.Security;
|
||||
|
||||
public static class ProductModuleCatalog
|
||||
{
|
||||
public static readonly IReadOnlyDictionary<string, string> All =
|
||||
new Dictionary<string, string>(StringComparer.Ordinal)
|
||||
{
|
||||
["dashboard"] = "Dashboard",
|
||||
["staff"] = "Staff",
|
||||
["role"] = "Roles",
|
||||
["student"] = "Students",
|
||||
["content"] = "Content",
|
||||
["settings"] = "Settings",
|
||||
["provider"] = "Providers",
|
||||
["commerce"] = "Commerce",
|
||||
["crm"] = "CRM",
|
||||
["commission"] = "Commission",
|
||||
["job"] = "Background Jobs"
|
||||
};
|
||||
|
||||
public static bool Contains(string moduleCode) =>
|
||||
All.ContainsKey(moduleCode.Trim().ToLowerInvariant());
|
||||
}
|
||||
138
Tiku.Application/Security/SaasFeatureCatalog.cs
Normal file
138
Tiku.Application/Security/SaasFeatureCatalog.cs
Normal file
@@ -0,0 +1,138 @@
|
||||
namespace Tiku.Application.Security;
|
||||
|
||||
public static class SaasFeatureCatalog
|
||||
{
|
||||
public const string CoreBackoffice = "core.backoffice";
|
||||
public const string PrivateQuestionBank = "question_bank.private";
|
||||
public const string Practice = "learning.practice";
|
||||
public const string Assignment = "learning.assignment";
|
||||
public const string Exam = "learning.exam";
|
||||
public const string Vocabulary = "content.vocabulary";
|
||||
public const string Handbook = "content.handbook";
|
||||
public const string Video = "content.video";
|
||||
public const string Scoreline = "content.scoreline";
|
||||
public const string SiteContent = "marketing.site_content";
|
||||
public const string StudentManagement = "student.management";
|
||||
public const string StudentStore = "commerce.student_store";
|
||||
public const string Crm = "crm.followup";
|
||||
public const string ReferralCommission = "growth.referral_commission";
|
||||
public const string TeacherAi = "ai.teacher_assistant";
|
||||
|
||||
public static readonly IReadOnlySet<string> All = new HashSet<string>(StringComparer.Ordinal)
|
||||
{
|
||||
CoreBackoffice,
|
||||
PrivateQuestionBank,
|
||||
Practice,
|
||||
Assignment,
|
||||
Exam,
|
||||
Vocabulary,
|
||||
Handbook,
|
||||
Video,
|
||||
Scoreline,
|
||||
SiteContent,
|
||||
StudentManagement,
|
||||
StudentStore,
|
||||
Crm,
|
||||
ReferralCommission,
|
||||
TeacherAi
|
||||
};
|
||||
|
||||
public static string? ResolveContentImportFeature(string? importType)
|
||||
{
|
||||
var normalized = importType?.Trim().ToLowerInvariant().Replace('-', '_');
|
||||
return normalized switch
|
||||
{
|
||||
"question" or "questions" or "question_bank" or "question_banks" => PrivateQuestionBank,
|
||||
"vocabulary" or "vocabulary_unit" or "vocabulary_units" or "vocabulary_word" or "vocabulary_words" => Vocabulary,
|
||||
"handbook" or "handbook_subject" or "handbook_subjects" or "handbook_chapter" or "handbook_chapters" or
|
||||
"handbook_entry" or "handbook_entries" => Handbook,
|
||||
"scoreline" or "scorelines" => Scoreline,
|
||||
"video" or "videos" => Video,
|
||||
_ => null
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
public static class PermissionModuleCatalog
|
||||
{
|
||||
public static readonly IReadOnlyDictionary<string, string?> RequiredFeatures =
|
||||
new Dictionary<string, string?>(StringComparer.Ordinal)
|
||||
{
|
||||
["tenant_dashboard"] = null,
|
||||
["tenant_staff"] = null,
|
||||
["tenant_settings"] = null,
|
||||
["tenant_provider"] = null,
|
||||
["tenant_job"] = null,
|
||||
["tenant_billing"] = null,
|
||||
["tenant_student"] = SaasFeatureCatalog.StudentManagement,
|
||||
["tenant_question_bank"] = SaasFeatureCatalog.PrivateQuestionBank,
|
||||
["tenant_vocabulary"] = SaasFeatureCatalog.Vocabulary,
|
||||
["tenant_handbook"] = SaasFeatureCatalog.Handbook,
|
||||
["tenant_video"] = SaasFeatureCatalog.Video,
|
||||
["tenant_scoreline"] = SaasFeatureCatalog.Scoreline,
|
||||
["tenant_site_content"] = SaasFeatureCatalog.SiteContent,
|
||||
["tenant_commerce"] = SaasFeatureCatalog.StudentStore,
|
||||
["tenant_crm"] = SaasFeatureCatalog.Crm,
|
||||
["tenant_commission"] = SaasFeatureCatalog.ReferralCommission,
|
||||
["platform_dashboard"] = null,
|
||||
["platform_tenant"] = null,
|
||||
["platform_staff"] = null,
|
||||
["platform_content"] = null,
|
||||
["platform_audit"] = null,
|
||||
["platform_billing"] = null,
|
||||
["commerce"] = SaasFeatureCatalog.StudentStore
|
||||
};
|
||||
|
||||
public static string ResolvePermissionModuleCode(string permissionCode) => permissionCode switch
|
||||
{
|
||||
BackendPermissions.TenantDashboardView => "tenant_dashboard",
|
||||
BackendPermissions.TenantStaffManage or BackendPermissions.TenantRoleManage => "tenant_staff",
|
||||
BackendPermissions.TenantStudentManage => "tenant_student",
|
||||
BackendPermissions.TenantContentManage => "tenant_question_bank",
|
||||
BackendPermissions.TenantVocabularyManage => "tenant_vocabulary",
|
||||
BackendPermissions.TenantHandbookManage => "tenant_handbook",
|
||||
BackendPermissions.TenantVideoManage => "tenant_video",
|
||||
BackendPermissions.TenantScorelineManage => "tenant_scoreline",
|
||||
BackendPermissions.TenantSiteContentManage => "tenant_site_content",
|
||||
BackendPermissions.TenantSettingsManage => "tenant_settings",
|
||||
BackendPermissions.TenantProviderManage => "tenant_provider",
|
||||
BackendPermissions.TenantCommerceOperate => "tenant_commerce",
|
||||
BackendPermissions.TenantCrmManage => "tenant_crm",
|
||||
BackendPermissions.TenantCommissionManage => "tenant_commission",
|
||||
BackendPermissions.TenantJobManage => "tenant_job",
|
||||
BackendPermissions.TenantBillingManage => "tenant_billing",
|
||||
BackendPermissions.PlatformDashboardView => "platform_dashboard",
|
||||
BackendPermissions.PlatformTenantManage => "platform_tenant",
|
||||
BackendPermissions.PlatformStaffManage or BackendPermissions.PlatformRoleManage => "platform_staff",
|
||||
BackendPermissions.PlatformQuestionBankManage => "platform_content",
|
||||
BackendPermissions.PlatformAuditView => "platform_audit",
|
||||
BackendPermissions.PlatformBillingNotification => "platform_billing",
|
||||
BackendPermissions.PlatformSaasCatalogManage or BackendPermissions.PlatformSaasBillingManage => "platform_billing",
|
||||
_ when permissionCode.StartsWith("commerce:", StringComparison.Ordinal) => "commerce",
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(permissionCode), permissionCode, "Permission module mapping is missing.")
|
||||
};
|
||||
}
|
||||
|
||||
public static class SaasQuotaMetricCatalog
|
||||
{
|
||||
public const string StaffCount = "staff.count";
|
||||
public const string StudentCount = "student.count";
|
||||
public const string PrivateQuestionCount = "private_question.count";
|
||||
public const string StorageBytes = "storage.bytes";
|
||||
public const string ImportCount = "import.count";
|
||||
public const string ExportCount = "export.count";
|
||||
public const string SmsCount = "sms.count";
|
||||
public const string AiCallCount = "ai.call.count";
|
||||
|
||||
public static readonly IReadOnlySet<string> All = new HashSet<string>(StringComparer.Ordinal)
|
||||
{
|
||||
StaffCount,
|
||||
StudentCount,
|
||||
PrivateQuestionCount,
|
||||
StorageBytes,
|
||||
ImportCount,
|
||||
ExportCount,
|
||||
SmsCount,
|
||||
AiCallCount
|
||||
};
|
||||
}
|
||||
@@ -7,6 +7,7 @@ public static class TikuPolicies
|
||||
public const string TenantBackofficeBootstrap = "tenant_backoffice_bootstrap";
|
||||
public const string PlatformBackofficeBootstrap = "platform_backoffice_bootstrap";
|
||||
public const string TenantContentManageAllScope = "tenant:content:manage:all_scope";
|
||||
public const string TenantAllDataScope = "tenant:data-scope:all";
|
||||
public const string TenantCommerceOperateAllScope = "tenant:commerce:operate:all_scope";
|
||||
|
||||
public const string TenantAdmin = "tenant_admin";
|
||||
|
||||
@@ -25,7 +25,9 @@ public sealed record TenantRuntimeBootstrap(
|
||||
JsonElement Theme,
|
||||
JsonElement Features,
|
||||
JsonElement Navigation,
|
||||
JsonElement HomeModules);
|
||||
JsonElement HomeModules,
|
||||
IReadOnlyCollection<string> EnabledFeatures,
|
||||
IReadOnlyCollection<string> LoginMethods);
|
||||
|
||||
public interface ITenantFrontendConfigService
|
||||
{
|
||||
|
||||
19
Tiku.Application/Tenancy/TenantOnboardingModels.cs
Normal file
19
Tiku.Application/Tenancy/TenantOnboardingModels.cs
Normal file
@@ -0,0 +1,19 @@
|
||||
namespace Tiku.Application.Tenancy;
|
||||
|
||||
public sealed record TenantOnboardingStep(
|
||||
string Code,
|
||||
bool Required,
|
||||
bool Completed,
|
||||
string? Detail);
|
||||
|
||||
public sealed record TenantOnboardingStatus(
|
||||
Guid TenantId,
|
||||
bool ReadyForStudentTraffic,
|
||||
int CompletedRequiredSteps,
|
||||
int RequiredSteps,
|
||||
IReadOnlyCollection<TenantOnboardingStep> Steps);
|
||||
|
||||
public interface ITenantOnboardingService
|
||||
{
|
||||
Task<TenantOnboardingStatus> GetStatusAsync(Guid tenantId, CancellationToken cancellationToken = default);
|
||||
}
|
||||
Reference in New Issue
Block a user