Tenant state management patterns for SaaS applications. Covers Trial, Active, Suspended, Deleted states with transitions, grace periods, and data retention.
Implements SaaS tenant state machines with trial, active, suspended, and deleted states. Triggers on payment events, subscription changes, or admin actions to automate transitions, grace periods, and compliance-based data retention.
/plugin marketplace add melodic-software/claude-code-plugins/plugin install saas-patterns@melodic-softwareThis skill is limited to using the following tools:
Patterns for managing tenant states throughout their lifecycle in multi-tenant SaaS applications.
Use this skill when:
Tenant lifecycle management handles the progression of tenants through states from trial to active to potential suspension and deletion. Proper lifecycle management protects revenue, ensures compliance, and maintains data integrity.
+------------------------------------------------------------------+
| Tenant Lifecycle States |
+------------------------------------------------------------------+
| |
| +---------+ +--------+ +----------+ +---------+ |
| | Trial |--->| Active |--->| Suspended|--->| Deleted | |
| +---------+ +--------+ +----------+ +---------+ |
| | | | ^ |
| | | | | |
| v v v | |
| +---------+ +--------+ +----------+ | |
| | Expired | | Past | | Grace |--------+ |
| | (churn) | | Due | | Period | |
| +---------+ +--------+ +----------+ |
| |
+------------------------------------------------------------------+
public enum TenantStatus
{
/// <summary>Trial period - limited features, time-bound</summary>
Trial,
/// <summary>Active subscription - full access</summary>
Active,
/// <summary>Payment past due - still active, dunning in progress</summary>
PastDue,
/// <summary>Suspended - read-only or no access, awaiting payment/action</summary>
Suspended,
/// <summary>Grace period before deletion - can be recovered</summary>
GracePeriod,
/// <summary>Scheduled for deletion - cleanup in progress</summary>
PendingDeletion,
/// <summary>Deleted - data removed (or anonymized)</summary>
Deleted,
/// <summary>Trial expired without conversion</summary>
Expired
}
State Transition Matrix:
+------------------------------------------------------------------+
| From State | To State | Trigger | Auto? |
+-----------------+-----------------+----------------------+-------+
| Trial | Active | Payment received | Yes |
| Trial | Expired | Trial period ends | Yes |
| Active | PastDue | Payment fails | Yes |
| Active | Suspended | Admin action | No |
| Active | GracePeriod | Cancellation request | Yes |
| PastDue | Active | Payment received | Yes |
| PastDue | Suspended | Dunning exhausted | Yes |
| Suspended | Active | Payment received | Yes |
| Suspended | GracePeriod | Max suspend time | Yes |
| GracePeriod | Active | Reactivation | No |
| GracePeriod | PendingDeletion | Grace period ends | Yes |
| PendingDeletion | Deleted | Cleanup complete | Yes |
| Expired | Active | Late conversion | No |
+-----------------+-----------------+----------------------+-------+
public sealed class TenantLifecycleService(
IDbContext db,
IEventPublisher events,
ITenantNotificationService notifications,
ILogger<TenantLifecycleService> logger)
{
public async Task<TransitionResult> TransitionAsync(
Guid tenantId,
TenantStatus newStatus,
string reason,
CancellationToken ct = default)
{
var tenant = await db.Tenants.FindAsync([tenantId], ct);
if (tenant is null)
return TransitionResult.NotFound();
var oldStatus = tenant.Status;
// Validate transition
if (!IsValidTransition(oldStatus, newStatus))
return TransitionResult.InvalidTransition(oldStatus, newStatus);
// Apply transition
tenant.Status = newStatus;
tenant.StatusChangedAt = DateTimeOffset.UtcNow;
tenant.StatusReason = reason;
// Set expiration dates based on new state
ApplyStateTimers(tenant, newStatus);
await db.SaveChangesAsync(ct);
// Publish event
await events.PublishAsync(new TenantStatusChangedEvent
{
TenantId = tenantId,
OldStatus = oldStatus,
NewStatus = newStatus,
Reason = reason,
Timestamp = DateTimeOffset.UtcNow
}, ct);
// Send notifications
await notifications.NotifyStatusChangeAsync(tenantId, oldStatus, newStatus, ct);
logger.LogInformation(
"Tenant {TenantId} transitioned from {OldStatus} to {NewStatus}: {Reason}",
tenantId, oldStatus, newStatus, reason);
return TransitionResult.Success(oldStatus, newStatus);
}
private static bool IsValidTransition(TenantStatus from, TenantStatus to)
{
return (from, to) switch
{
(TenantStatus.Trial, TenantStatus.Active) => true,
(TenantStatus.Trial, TenantStatus.Expired) => true,
(TenantStatus.Active, TenantStatus.PastDue) => true,
(TenantStatus.Active, TenantStatus.Suspended) => true,
(TenantStatus.Active, TenantStatus.GracePeriod) => true,
(TenantStatus.PastDue, TenantStatus.Active) => true,
(TenantStatus.PastDue, TenantStatus.Suspended) => true,
(TenantStatus.Suspended, TenantStatus.Active) => true,
(TenantStatus.Suspended, TenantStatus.GracePeriod) => true,
(TenantStatus.GracePeriod, TenantStatus.Active) => true,
(TenantStatus.GracePeriod, TenantStatus.PendingDeletion) => true,
(TenantStatus.PendingDeletion, TenantStatus.Deleted) => true,
(TenantStatus.Expired, TenantStatus.Active) => true,
_ => false
};
}
private static void ApplyStateTimers(Tenant tenant, TenantStatus newStatus)
{
tenant.GracePeriodEndsAt = newStatus switch
{
TenantStatus.GracePeriod => DateTimeOffset.UtcNow.AddDays(30),
_ => null
};
tenant.DeletionScheduledAt = newStatus switch
{
TenantStatus.PendingDeletion => DateTimeOffset.UtcNow.AddDays(7),
_ => null
};
}
}
public sealed record TrialConfiguration
{
public required int TrialDays { get; init; } = 14;
public required bool RequireCreditCard { get; init; } = false;
public required List<string> TrialFeatures { get; init; }
public required int TrialUserLimit { get; init; } = 5;
public required bool AllowTrialExtension { get; init; } = true;
public required int MaxExtensionDays { get; init; } = 7;
}
public sealed class TrialExpirationJob(
IDbContext db,
ITenantLifecycleService lifecycle,
ILogger<TrialExpirationJob> logger) : IScheduledJob
{
public async Task ExecuteAsync(CancellationToken ct)
{
var expiredTrials = await db.Tenants
.Where(t => t.Status == TenantStatus.Trial)
.Where(t => t.TrialEndsAt <= DateTimeOffset.UtcNow)
.ToListAsync(ct);
foreach (var tenant in expiredTrials)
{
await lifecycle.TransitionAsync(
tenant.Id,
TenantStatus.Expired,
"Trial period ended",
ct);
}
logger.LogInformation("Expired {Count} trial tenants", expiredTrials.Count);
}
}
public async Task<ExtensionResult> ExtendTrialAsync(
Guid tenantId,
int days,
string reason,
CancellationToken ct)
{
var tenant = await db.Tenants.FindAsync([tenantId], ct);
if (tenant is null)
return ExtensionResult.NotFound();
if (tenant.Status != TenantStatus.Trial)
return ExtensionResult.NotInTrial();
var config = await GetTrialConfigAsync(ct);
if (!config.AllowTrialExtension)
return ExtensionResult.ExtensionsDisabled();
var totalExtension = tenant.TrialExtensionDays + days;
if (totalExtension > config.MaxExtensionDays)
return ExtensionResult.MaxExtensionExceeded(config.MaxExtensionDays);
tenant.TrialEndsAt = tenant.TrialEndsAt!.Value.AddDays(days);
tenant.TrialExtensionDays = totalExtension;
tenant.TrialExtensionReason = reason;
await db.SaveChangesAsync(ct);
return ExtensionResult.Success(tenant.TrialEndsAt.Value);
}
Suspension Modes:
+------------------------------------------------------------------+
| Mode | Access Level | Use Case |
+-------------------+--------------------+-------------------------+
| Read-Only | View data only | Payment issues |
| Admin-Only | Only admins access | Policy violation review |
| Full Block | No access | Serious violation |
| Degraded | Core features only | Temporary capacity |
+-------------------+--------------------+-------------------------+
public sealed class TenantAccessMiddleware(
RequestDelegate next,
ITenantContext tenantContext)
{
public async Task InvokeAsync(HttpContext context)
{
var tenant = await tenantContext.GetCurrentTenantAsync();
var accessResult = tenant?.Status switch
{
TenantStatus.Trial => AccessResult.FullAccess(),
TenantStatus.Active => AccessResult.FullAccess(),
TenantStatus.PastDue => AccessResult.FullAccess(), // Grace during dunning
TenantStatus.Suspended => CheckSuspensionAccess(context, tenant),
TenantStatus.GracePeriod => AccessResult.ReadOnly(),
TenantStatus.PendingDeletion => AccessResult.Blocked("Account scheduled for deletion"),
TenantStatus.Deleted => AccessResult.Blocked("Account has been deleted"),
TenantStatus.Expired => AccessResult.Blocked("Trial has expired"),
null => AccessResult.Blocked("Tenant not found"),
_ => AccessResult.Blocked("Unknown status")
};
if (!accessResult.IsAllowed)
{
context.Response.StatusCode = 403;
await context.Response.WriteAsJsonAsync(new
{
error = "access_denied",
message = accessResult.Message,
tenantStatus = tenant?.Status.ToString()
});
return;
}
if (accessResult.IsReadOnly)
{
context.Items["ReadOnlyMode"] = true;
}
await next(context);
}
}
public sealed record DeletionConfiguration
{
public required int GracePeriodDays { get; init; } = 30;
public required int DeletionDelayDays { get; init; } = 7;
public required bool AnonymizeInsteadOfDelete { get; init; } = true;
public required List<int> ReminderDays { get; init; } = [7, 3, 1];
}
public sealed class TenantDeletionService(
IDbContext db,
IStorageService storage,
IAuditLog audit,
ILogger<TenantDeletionService> logger)
{
public async Task<DeletionResult> DeleteTenantAsync(
Guid tenantId,
DeletionConfiguration config,
CancellationToken ct)
{
var tenant = await db.Tenants.FindAsync([tenantId], ct);
if (tenant is null)
return DeletionResult.NotFound();
if (tenant.Status != TenantStatus.PendingDeletion)
return DeletionResult.InvalidState(tenant.Status);
logger.LogInformation("Starting deletion for tenant {TenantId}", tenantId);
try
{
// Step 1: Export data (if required for compliance)
await ExportTenantDataAsync(tenantId, ct);
// Step 2: Delete or anonymize user data
if (config.AnonymizeInsteadOfDelete)
{
await AnonymizeTenantDataAsync(tenantId, ct);
}
else
{
await DeleteTenantDataAsync(tenantId, ct);
}
// Step 3: Delete storage
await storage.DeleteTenantStorageAsync(tenantId, ct);
// Step 4: Mark as deleted
tenant.Status = TenantStatus.Deleted;
tenant.DeletedAt = DateTimeOffset.UtcNow;
await db.SaveChangesAsync(ct);
// Step 5: Audit log (immutable record)
await audit.LogAsync(new AuditEntry
{
TenantId = tenantId,
Action = "tenant_deleted",
Details = new { AnonymizedOnly = config.AnonymizeInsteadOfDelete },
Timestamp = DateTimeOffset.UtcNow
}, ct);
return DeletionResult.Success();
}
catch (Exception ex)
{
logger.LogError(ex, "Deletion failed for tenant {TenantId}", tenantId);
return DeletionResult.Failed(ex.Message);
}
}
private async Task AnonymizeTenantDataAsync(Guid tenantId, CancellationToken ct)
{
// Anonymize PII while retaining aggregate data
await db.Database.ExecuteSqlInterpolatedAsync($@"
UPDATE Users
SET Email = CONCAT('deleted_', Id, '@anonymized.local'),
FirstName = 'Deleted',
LastName = 'User',
Phone = NULL
WHERE TenantId = {tenantId}", ct);
// Similar anonymization for other PII-containing tables
}
}
Notification Triggers:
+------------------------------------------------------------------+
| Event | Channel | Timing |
+------------------------+------------+----------------------------+
| Trial starting | Email | Immediate |
| Trial ending soon | Email | 3 days, 1 day before |
| Trial expired | Email | Immediate |
| Payment failed | Email+App | Immediate |
| Account suspended | Email | Immediate |
| Grace period starting | Email | Immediate |
| Grace period ending | Email | 7, 3, 1 days before |
| Deletion scheduled | Email | Immediate |
| Account reactivated | Email | Immediate |
+------------------------+------------+----------------------------+
public sealed class TenantNotificationService(
IEmailService email,
IInAppNotifications inApp) : ITenantNotificationService
{
public async Task NotifyStatusChangeAsync(
Guid tenantId,
TenantStatus oldStatus,
TenantStatus newStatus,
CancellationToken ct)
{
var template = (oldStatus, newStatus) switch
{
(TenantStatus.Trial, TenantStatus.Active) => "trial_converted",
(TenantStatus.Trial, TenantStatus.Expired) => "trial_expired",
(TenantStatus.Active, TenantStatus.PastDue) => "payment_failed",
(TenantStatus.PastDue, TenantStatus.Suspended) => "account_suspended",
(TenantStatus.Active, TenantStatus.GracePeriod) => "cancellation_started",
(TenantStatus.GracePeriod, TenantStatus.PendingDeletion) => "deletion_scheduled",
(_, TenantStatus.Active) => "account_reactivated",
_ => null
};
if (template is not null)
{
await email.SendTemplateAsync(tenantId, template, ct);
}
}
}
public sealed class LifecycleScheduler(IServiceProvider services)
{
public void ConfigureJobs(IRecurringJobManager jobs)
{
// Check trial expirations every hour
jobs.AddOrUpdate<TrialExpirationJob>(
"trial-expiration",
job => job.ExecuteAsync(default),
Cron.Hourly);
// Check grace period expirations daily
jobs.AddOrUpdate<GracePeriodExpirationJob>(
"grace-period-expiration",
job => job.ExecuteAsync(default),
Cron.Daily);
// Process pending deletions daily
jobs.AddOrUpdate<TenantDeletionJob>(
"tenant-deletion",
job => job.ExecuteAsync(default),
Cron.Daily);
// Send reminder emails daily
jobs.AddOrUpdate<LifecycleReminderJob>(
"lifecycle-reminders",
job => job.ExecuteAsync(default),
"0 9 * * *"); // 9 AM daily
}
}
Lifecycle Management Best Practices:
+------------------------------------------------------------------+
| Practice | Benefit |
+-----------------------------+------------------------------------+
| Clear state machine | Predictable transitions |
| Grace periods | Revenue recovery, compliance |
| Notification cadence | User awareness, reduce churn |
| Soft delete first | Recovery possible, audit trail |
| Anonymize vs delete | GDPR compliance, analytics |
| Status in JWT claims | Fast access checks |
| Event-driven transitions | Decoupled, auditable |
+-----------------------------+------------------------------------+
| Anti-Pattern | Problem | Solution |
|---|---|---|
| Hard delete immediately | No recovery, compliance issues | Grace period + soft delete |
| Status in UI only | Inconsistent enforcement | Middleware check on every request |
| No audit trail | Compliance/legal risk | Event sourcing for transitions |
| Silent suspension | User confusion | Clear notifications |
| One-size grace period | Revenue loss | Tier-based grace periods |
Load for detailed implementation:
references/lifecycle-states.md - State machine detailsreferences/deletion-compliance.md - GDPR/CCPA deletion requirementstenant-provisioning - Initial tenant creationsubscription-models - Payment status integrationaudit-logging - Lifecycle event loggingsaas-compliance-frameworks - Deletion complianceFor current lifecycle patterns:
perplexity: "SaaS tenant lifecycle 2024" "tenant soft delete GDPR"
microsoft-learn: "Azure AD B2C tenant management" "subscription state machine"
Use when working with Payload CMS projects (payload.config.ts, collections, fields, hooks, access control, Payload API). Use when debugging validation errors, security issues, relationship queries, transactions, or hook behavior.
Creating algorithmic art using p5.js with seeded randomness and interactive parameter exploration. Use this when users request creating art using code, generative art, algorithmic art, flow fields, or particle systems. Create original algorithmic art rather than copying existing artists' work to avoid copyright violations.