Account Tiers Implementation

Status: โœ… Implemented (Teams tier in progress)

Developer Note: This document describes the technical design and implementation of the account tiers system. For user-facing documentation, see Account Types & Tiers.

Overview

CheckTick implements a seven-tier account system designed to work seamlessly for both self-hosted deployments and hosted SaaS offerings. The system supports individual users, collaborative teams, and organisational hierarchies.

Account Tiers

1. FREE

Entry-level individual account with 3 survey limit.

2. PRO

Paid individual account with unlimited surveys.

3. TEAM (Small/Medium/Large)

Collaborative teams of 5-20 users with shared billing and role-based access.

4. ORGANISATION

Multi-team organisations with private datasets, unlimited members, and full governance features.

5. ENTERPRISE

Self-hosted deployments with custom branding, SSO, and full control.


Data Models

Team Model

class Team(models.Model):
    name = CharField(max_length=255)
    owner = ForeignKey(User)
    organization = ForeignKey(Organization, null=True, blank=True)  # Optional parent

    SIZE_CHOICES = [
        ('small', 'Small (5 users)'),
        ('medium', 'Medium (10 users)'),
        ('large', 'Large (20 users)'),
        ('custom', 'Custom (>20 users)'),
    ]
    size = CharField(max_length=20, choices=SIZE_CHOICES)
    custom_max_members = PositiveIntegerField(null=True, blank=True)
    max_surveys = PositiveIntegerField(default=50)

    subscription_id = CharField(max_length=255, blank=True)  # Generic billing reference
    encrypted_master_key = BinaryField(null=True, blank=True)  # For Phase 2 - Vault

Key Properties: - max_members - Returns 5/10/20 based on size, or custom_max_members - current_member_count() - Count of team memberships - can_add_members() - Check if under capacity - current_survey_count() - Count of team surveys - can_create_surveys() - Check if under survey limit

TeamMembership Model

class TeamMembership(models.Model):
    team = ForeignKey(Team)
    user = ForeignKey(User)
    role = CharField(choices=[('admin', 'Admin'), ('creator', 'Creator'), ('viewer', 'Viewer')])

    class Meta:
        unique_together = ("team", "user")

Roles: - Admin: Manage team members, settings, and all surveys - Creator: Create and edit surveys within team - Viewer: Read-only access to team surveys

Role Persistence: Roles remain intact if team migrates to Organisation.

Survey Model Updates

class Survey(models.Model):
    owner = ForeignKey(User)           # Required - survey creator
    organization = ForeignKey(Organization, null=True)  # Optional - org context
    team = ForeignKey(Team, null=True, blank=True)      # Optional - team context

Access Hierarchy: Organisation admin > Team admin > Survey owner


Feature Matrix

Feature Individual (Free) Individual Pro Team Small Team Medium Team Large Organization Enterprise
Team Management
Maximum Team Members 1 1 5 10 20 Unlimited Unlimited
Team Membership Roles - - ADMIN, CREATOR, VIEWER ADMIN, CREATOR, VIEWER ADMIN, CREATOR, VIEWER - -
Organization Membership โŒ โŒ Optional parent Optional parent Optional parent โœ… โœ…
Organization Roles - - - - - ADMIN, CREATOR, VIEWER ADMIN, CREATOR, VIEWER
Survey Creation
Maximum Surveys 3 Unlimited 50 50 50 Unlimited Unlimited
Drag & Drop Builder โœ… โœ… โœ… โœ… โœ… โœ… โœ…
Text Entry for Surveys โœ… โœ… โœ… โœ… โœ… โœ… โœ…
Survey Templates โœ… โœ… โœ… โœ… โœ… โœ… โœ…
Conditional Logic/Branching โœ… โœ… โœ… โœ… โœ… โœ… โœ…
Question Groups โœ… โœ… โœ… โœ… โœ… โœ… โœ…
Repeating Groups โœ… โœ… โœ… โœ… โœ… โœ… โœ…
AI Features
AI Survey Assistant โœ… โœ… โœ… โœ… โœ… โœ… โœ…
AI-Generated Survey Translations โœ… โœ… โœ… โœ… โœ… โœ… โœ…
Internationalization
Multi-language Interface (i18n) โœ… โœ… โœ… โœ… โœ… โœ… โœ…
Survey Translation Support โœ… โœ… โœ… โœ… โœ… โœ… โœ…
All Supported Languages โœ… โœ… โœ… โœ… โœ… โœ… โœ…
Collaboration
Survey Ownership Single Owner Single Owner Team-based Team-based Team-based Team-based Team-based
Add Collaborators โŒ โœ… (Editors only) โœ… (Full roles) โœ… (Full roles) โœ… (Full roles) โœ… (Full roles) โœ… (Full roles)
Survey Membership Roles - EDITOR CREATOR, EDITOR, VIEWER CREATOR, EDITOR, VIEWER CREATOR, EDITOR, VIEWER CREATOR, EDITOR, VIEWER CREATOR, EDITOR, VIEWER
Sub-organizations/Departments โŒ โŒ โŒ โŒ โŒ โŒ โœ…
Sharing & Publishing
Question Group Sharing โœ… โœ… โœ… โœ… โœ… โœ… โœ…
Published Question Groups โœ… โœ… โœ… โœ… โœ… โœ… โœ…
Dataset Sharing โœ… โœ… โœ… โœ… โœ… โœ… โœ…
Published Datasets โœ… โœ… โœ… โœ… โœ… โœ… โœ…
Import Shared Resources โœ… โœ… โœ… โœ… โœ… โœ… โœ…
Security & Encryption
End-to-End Encryption โœ… โœ… โœ… โœ… โœ… โœ… โœ…
Personal Passphrase Encryption โœ… โœ… โœ… โœ… โœ… โœ… โœ…
Team Key Management โŒ โŒ โœ… โœ… โœ… - -
Organization Key Management โŒ โŒ โŒ โŒ โŒ โœ… โœ…
Shamir Secret Sharing (Recovery) โœ… โœ… โœ… โœ… โœ… โœ… โœ…
Audit Logging โœ… โœ… โœ… โœ… โœ… โœ… โœ…
Data Governance Features โœ… โœ… โœ… โœ… โœ… โœ… โœ…
Legal Hold Support โœ… โœ… โœ… โœ… โœ… โœ… โœ…
Data Retention Policies โœ… โœ… โœ… โœ… โœ… โœ… โœ…
Authentication
Username/Password โœ… โœ… โœ… โœ… โœ… โœ… โœ…
SSO (Google/Azure) โœ… โœ… โœ… โœ… โœ… โœ… โœ…
SAML/Enterprise SSO โŒ โŒ โŒ โŒ โŒ โŒ โœ… (optional)
Data & Analytics
Response Collection Unlimited Unlimited Unlimited Unlimited Unlimited Unlimited Unlimited
Data Export (CSV/JSON/Excel) โœ… โœ… โœ… โœ… โœ… โœ… โœ…
Advanced Analytics Dashboard โŒ โŒ โŒ โŒ โŒ โŒ โœ…
Organization-wide Reports โŒ โŒ โŒ โŒ โŒ โœ… โœ…
API Access
REST API Access โœ… โœ… โœ… โœ… โœ… โœ… โœ…
API Rate Limits Standard Standard Standard Standard Standard Standard 10x Increased
API Documentation โœ… โœ… โœ… โœ… โœ… โœ… โœ…
Branding & Customization
Site Theme Selection View Only View Only View Only View Only View Only View Only โœ… Configure
Custom Logo Upload โŒ โŒ โŒ โŒ โŒ โŒ โœ…
Custom Brand Name โŒ โŒ โŒ โŒ โŒ โŒ โœ…
White-labeling โŒ โŒ โŒ โŒ โŒ โŒ โœ…
Organization Themes/Colors โŒ โŒ โŒ โŒ โŒ โœ… โœ…
Survey-level Styling โœ… โœ… โœ… โœ… โœ… โœ… โœ…
Support
Community Support โœ… โœ… โœ… โœ… โœ… โœ… โœ…
Email Support Standard Standard Standard Standard Standard Standard Priority
SLA Guarantee โŒ โŒ โŒ โŒ โŒ โŒ โœ…
Dedicated Onboarding โŒ โŒ โŒ โŒ โŒ โŒ โœ…
Training Sessions โŒ โŒ โŒ โŒ โŒ โŒ โœ…

Self-Hosted Deployments

Key Principle: Self-hosted instances get Enterprise tier features without payment requirements.

Configuration

# .env file
SELF_HOSTED=true

When SELF_HOSTED=true: - All users automatically get Enterprise tier capabilities - Billing UI is completely hidden - no payment pages, upgrade prompts, or subscription management - No payment integration required - No survey limits enforced - Full branding customization available - All collaboration features enabled - No tier restrictions applied - Pricing page still visible but marked as reference for hosted version

Branding Management for Self-Hosted

Self-hosted administrators can configure branding via: 1. UI Dashboard (recommended): Navigate to /admin/branding/ (requires superuser) 2. Management Command: python manage.py configure_branding 3. Direct Database: Edit SiteBranding model (not recommended)


Hosted SaaS Deployments

Configuration

# .env file
SELF_HOSTED=false
PAYMENT_PROVIDER=ryft  # or other provider
PAYMENT_API_KEY=your_api_key_here

Tier Enforcement

When SELF_HOSTED=false: - Survey limits enforced for FREE tier (3 surveys max) - Collaboration features gated by tier - Branding features restricted to Enterprise tier - Payment integration active - Subscription status checked


Technical Implementation

1. UserProfile Model

Location: checktick_app/core/models.py

class UserProfile(models.Model):
    """
    User account tier and subscription tracking.

    Handles both self-hosted (free Enterprise features) and hosted SaaS
    (payment-gated tiers) deployments.
    """

    class AccountTier(models.TextChoices):
        FREE = 'free', 'Individual (Free)'
        PRO = 'pro', 'Individual Pro'
        ORGANIZATION = 'organization', 'Organization'
        ENTERPRISE = 'enterprise', 'Enterprise'

    user = models.OneToOneField(
        User,
        on_delete=models.CASCADE,
        related_name='profile'
    )
    account_tier = models.CharField(
        max_length=20,
        choices=AccountTier.choices,
        default=AccountTier.FREE
    )

    # Generic payment tracking (provider-agnostic)
    payment_provider = models.CharField(
        max_length=50,
        blank=True,
        help_text="Payment provider name (e.g., 'ryft', 'stripe')"
    )
    payment_customer_id = models.CharField(
        max_length=255,
        blank=True,
        help_text="Customer ID in payment provider system"
    )
    payment_subscription_id = models.CharField(
        max_length=255,
        blank=True,
        help_text="Subscription ID in payment provider system"
    )
    subscription_status = models.CharField(
        max_length=20,
        default='inactive',
        help_text="active, inactive, cancelled, past_due, etc."
    )
    subscription_period_end = models.DateTimeField(
        null=True,
        blank=True,
        help_text="When current subscription period ends"
    )

    # Enterprise branding (only for ENTERPRISE tier)
    custom_brand_name = models.CharField(
        max_length=255,
        blank=True,
        help_text="Custom brand name to replace 'CheckTick'"
    )
    custom_logo_url = models.URLField(
        blank=True,
        help_text="URL to custom logo image"
    )
    custom_logo_file = models.FileField(
        upload_to='enterprise_branding/',
        blank=True,
        null=True,
        help_text="Uploaded custom logo file"
    )

    created_at = models.DateTimeField(auto_now_add=True)
    updated_at = models.DateTimeField(auto_now=True)

    class Meta:
        verbose_name = "User Profile"
        verbose_name_plural = "User Profiles"

    def __str__(self):
        return f"{self.user.username} - {self.get_account_tier_display()}"

    def get_effective_tier(self):
        """
        Returns the effective tier, accounting for self-hosted instances.

        Self-hosted instances get Enterprise features without payment.
        """
        from django.conf import settings
        if getattr(settings, 'SELF_HOSTED', False):
            return self.AccountTier.ENTERPRISE
        return self.account_tier

    def can_create_survey(self):
        """
        Check if user can create another survey based on their tier.

        Returns:
            bool: True if user can create a survey, False otherwise
        """
        from django.conf import settings
        from checktick_app.surveys.models import Survey

        if getattr(settings, 'SELF_HOSTED', False):
            return True  # No limits for self-hosted

        tier = self.account_tier
        if tier == self.AccountTier.FREE:
            survey_count = Survey.objects.filter(owner=self.user).count()
            return survey_count < 3

        # PRO, ORGANIZATION, ENTERPRISE have unlimited surveys
        return True

    def can_add_collaborators(self):
        """
        Check if user can add collaborators to their surveys.

        Returns:
            bool: True if user can add collaborators
        """
        from django.conf import settings

        if getattr(settings, 'SELF_HOSTED', False):
            return True

        tier = self.account_tier
        return tier in [
            self.AccountTier.PRO,
            self.AccountTier.ORGANIZATION,
            self.AccountTier.ENTERPRISE
        ]

    def can_add_viewers(self):
        """
        Check if user can add VIEWER role collaborators.

        Only ORGANIZATION and ENTERPRISE tiers support the VIEWER role.
        PRO tier can only add EDITORs.

        Returns:
            bool: True if user can add viewers
        """
        from django.conf import settings

        if getattr(settings, 'SELF_HOSTED', False):
            return True

        tier = self.account_tier
        return tier in [
            self.AccountTier.ORGANIZATION,
            self.AccountTier.ENTERPRISE
        ]

    def can_customize_branding(self):
        """
        Check if user can customize site branding.

        Available to:
        - Enterprise tier in hosted mode
        - Any superuser in self-hosted mode

        Returns:
            bool: True if user can customize branding
        """
        from django.conf import settings

        if getattr(settings, 'SELF_HOSTED', False):
            return self.user.is_superuser

        return self.account_tier == self.AccountTier.ENTERPRISE

    def get_branding_settings(self):
        """
        Get the branding settings for this user.

        Returns:
            dict or None: Branding configuration or None if not available
        """
        if not self.can_customize_branding():
            return None

        logo_url = self.custom_logo_url
        if not logo_url and self.custom_logo_file:
            logo_url = self.custom_logo_file.url

        return {
            'brand_name': self.custom_brand_name or 'CheckTick',
            'logo_url': logo_url,
        }

    def get_api_rate_limit(self):
        """
        Get API rate limit multiplier for this user's tier.

        Returns:
            int: Rate limit multiplier (1 for standard, 10 for enterprise)
        """
        from django.conf import settings

        if getattr(settings, 'SELF_HOSTED', False):
            return 10  # Enterprise limits for self-hosted

        if self.account_tier == self.AccountTier.ENTERPRISE:
            return 10

        return 1  # Standard rate limits

2. Database Migration

Location: checktick_app/core/migrations/000X_add_user_profile.py

from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion


def create_user_profiles(apps, schema_editor):
    """Create UserProfile for all existing users with FREE tier."""
    User = apps.get_model(settings.AUTH_USER_MODEL)
    UserProfile = apps.get_model('core', 'UserProfile')

    for user in User.objects.all():
        UserProfile.objects.get_or_create(
            user=user,
            defaults={'account_tier': 'free'}
        )


class Migration(migrations.Migration):

    dependencies = [
        migrations.swappable_dependency(settings.AUTH_USER_MODEL),
        ('core', 'XXXX_previous_migration'),
    ]

    operations = [
        migrations.CreateModel(
            name='UserProfile',
            fields=[
                ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
                ('account_tier', models.CharField(
                    choices=[
                        ('free', 'Individual (Free)'),
                        ('pro', 'Individual Pro'),
                        ('organization', 'Organization'),
                        ('enterprise', 'Enterprise')
                    ],
                    default='free',
                    max_length=20
                )),
                ('payment_provider', models.CharField(blank=True, max_length=50)),
                ('payment_customer_id', models.CharField(blank=True, max_length=255)),
                ('payment_subscription_id', models.CharField(blank=True, max_length=255)),
                ('subscription_status', models.CharField(default='inactive', max_length=20)),
                ('subscription_period_end', models.DateTimeField(blank=True, null=True)),
                ('custom_brand_name', models.CharField(blank=True, max_length=255)),
                ('custom_logo_url', models.URLField(blank=True)),
                ('custom_logo_file', models.FileField(blank=True, null=True, upload_to='enterprise_branding/')),
                ('created_at', models.DateTimeField(auto_now_add=True)),
                ('updated_at', models.DateTimeField(auto_now=True)),
                ('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='profile', to=settings.AUTH_USER_MODEL)),
            ],
            options={
                'verbose_name': 'User Profile',
                'verbose_name_plural': 'User Profiles',
            },
        ),
        migrations.RunPython(create_user_profiles, reverse_code=migrations.RunPython.noop),
    ]

3. Signal Handler for Auto-Profile Creation

Location: checktick_app/core/signals.py

from django.contrib.auth import get_user_model
from django.db.models.signals import post_save
from django.dispatch import receiver

from .models import UserProfile

User = get_user_model()


@receiver(post_save, sender=User)
def create_user_profile(sender, instance, created, **kwargs):
    """Automatically create UserProfile when User is created."""
    if created:
        UserProfile.objects.get_or_create(user=instance)


@receiver(post_save, sender=User)
def save_user_profile(sender, instance, **kwargs):
    """Save UserProfile when User is saved."""
    if hasattr(instance, 'profile'):
        instance.profile.save()

Register signals in: checktick_app/core/apps.py

from django.apps import AppConfig


class CoreConfig(AppConfig):
    default_auto_field = 'django.db.models.BigAutoField'
    name = 'checktick_app.core'

    def ready(self):
        import checktick_app.core.signals  # noqa

4. Management Command: configure_branding

Location: checktick_app/core/management/commands/configure_branding.py

from django.core.management.base import BaseCommand
from django.core.files import File

from checktick_app.core.models import SiteBranding


class Command(BaseCommand):
    help = 'Configure site branding (logo, theme, colors)'

    def add_arguments(self, parser):
        parser.add_argument(
            '--theme-light',
            type=str,
            help='DaisyUI theme preset for light mode (e.g., nord, cupcake, light)'
        )
        parser.add_argument(
            '--theme-dark',
            type=str,
            help='DaisyUI theme preset for dark mode (e.g., business, dark, synthwave)'
        )
        parser.add_argument(
            '--logo',
            type=str,
            help='Path to logo file'
        )
        parser.add_argument(
            '--logo-dark',
            type=str,
            help='Path to dark mode logo file'
        )
        parser.add_argument(
            '--brand-name',
            type=str,
            help='Custom brand name (stored in SiteBranding for display purposes)'
        )
        parser.add_argument(
            '--default-theme',
            type=str,
            choices=['checktick-light', 'checktick-dark'],
            help='Default theme selection'
        )

    def handle(self, *args, **options):
        branding, created = SiteBranding.objects.get_or_create(pk=1)

        if created:
            self.stdout.write(self.style.SUCCESS('Created new SiteBranding configuration'))

        updated = False

        if options['theme_light']:
            branding.theme_preset_light = options['theme_light']
            updated = True
            self.stdout.write(f"Set light theme to: {options['theme_light']}")

        if options['theme_dark']:
            branding.theme_preset_dark = options['theme_dark']
            updated = True
            self.stdout.write(f"Set dark theme to: {options['theme_dark']}")

        if options['logo']:
            with open(options['logo'], 'rb') as f:
                branding.icon_file.save(options['logo'].split('/')[-1], File(f), save=False)
            updated = True
            self.stdout.write(f"Uploaded logo: {options['logo']}")

        if options['logo_dark']:
            with open(options['logo_dark'], 'rb') as f:
                branding.icon_file_dark.save(options['logo_dark'].split('/')[-1], File(f), save=False)
            updated = True
            self.stdout.write(f"Uploaded dark logo: {options['logo_dark']}")

        if options['default_theme']:
            branding.default_theme = options['default_theme']
            updated = True
            self.stdout.write(f"Set default theme to: {options['default_theme']}")

        if updated:
            branding.save()
            self.stdout.write(self.style.SUCCESS('โœ“ Branding configuration updated successfully'))
        else:
            self.stdout.write(self.style.WARNING('No changes specified'))
            self.stdout.write('\nCurrent configuration:')
            self.stdout.write(f"  Default theme: {branding.default_theme}")
            self.stdout.write(f"  Light theme preset: {branding.theme_preset_light or '(not set)'}")
            self.stdout.write(f"  Dark theme preset: {branding.theme_preset_dark or '(not set)'}")
            self.stdout.write(f"  Logo: {branding.icon_file.url if branding.icon_file else '(not set)'}")

5. Branding Configuration View

Location: checktick_app/core/views.py (new or existing)

from django.contrib.auth.decorators import login_required, user_passes_test
from django.shortcuts import render, redirect
from django.contrib import messages
from django.views.decorators.http import require_http_methods

from .models import SiteBranding
from .forms import BrandingConfigForm


def can_configure_branding(user):
    """Check if user can access branding configuration."""
    if not user.is_authenticated:
        return False

    # Self-hosted: superusers can configure
    from django.conf import settings
    if getattr(settings, 'SELF_HOSTED', False):
        return user.is_superuser

    # Hosted: Enterprise tier users can configure
    if hasattr(user, 'profile'):
        return user.profile.account_tier == 'enterprise'

    return False


@login_required
@user_passes_test(can_configure_branding)
@require_http_methods(["GET", "POST"])
def configure_branding(request):
    """View to configure site branding."""
    branding, created = SiteBranding.objects.get_or_create(pk=1)

    if request.method == 'POST':
        form = BrandingConfigForm(request.POST, request.FILES, instance=branding)
        if form.is_valid():
            form.save()
            messages.success(request, 'Branding configuration updated successfully!')
            return redirect('core:configure_branding')
    else:
        form = BrandingConfigForm(instance=branding)

    return render(request, 'core/configure_branding.html', {
        'form': form,
        'branding': branding,
    })

Form Location: checktick_app/core/forms.py

from django import forms
from .models import SiteBranding


class BrandingConfigForm(forms.ModelForm):
    """Form for configuring site branding."""

    class Meta:
        model = SiteBranding
        fields = [
            'default_theme',
            'theme_preset_light',
            'theme_preset_dark',
            'icon_file',
            'icon_file_dark',
            'icon_url',
            'icon_url_dark',
            'font_heading',
            'font_body',
            'font_css_url',
        ]
        widgets = {
            'theme_preset_light': forms.TextInput(attrs={
                'class': 'input input-bordered w-full',
                'placeholder': 'e.g., nord, cupcake, light'
            }),
            'theme_preset_dark': forms.TextInput(attrs={
                'class': 'input input-bordered w-full',
                'placeholder': 'e.g., business, dark, synthwave'
            }),
        }

6. Permission Checks Integration

Update existing permission functions to check tier restrictions:

Location: checktick_app/surveys/permissions.py

def can_manage_survey_users(user, survey):
    """
    Check if user can manage survey collaborators.

    Enhanced to check account tier restrictions:
    - Individual accounts (FREE tier): Cannot manage users
    - Individual Pro: Can add editors only
    - Organization/Enterprise: Full user management
    """
    if not user.is_authenticated:
        return False

    # Check tier restrictions
    if hasattr(user, 'profile'):
        if not user.profile.can_add_collaborators():
            return False

    # Survey without organization (individual survey)
    if not survey.organization:
        # Individual surveys: only owner can manage, and only if tier allows
        if survey.owner == user:
            return hasattr(user, 'profile') and user.profile.can_add_collaborators()
        return False

    # Organization survey: existing logic
    org = survey.organization
    membership = OrganizationMembership.objects.filter(
        user=user, organization=org
    ).first()

    if not membership:
        return False

    return membership.role in [
        OrganizationMembership.Role.ADMIN,
        OrganizationMembership.Role.CREATOR
    ]

Location: checktick_app/surveys/views.py (survey creation)

@login_required
@require_http_methods(["GET", "POST"])
def create_survey(request):
    """Create a new survey with tier limit checks."""

    # Check if user can create another survey
    if hasattr(request.user, 'profile'):
        if not request.user.profile.can_create_survey():
            messages.error(
                request,
                'You have reached your survey limit. Upgrade to Pro for unlimited surveys.'
            )
            return redirect('surveys:list')

    # ... existing survey creation logic

7. Settings Configuration

Location: checktick_app/settings.py

import os

# Self-hosting configuration
SELF_HOSTED = os.environ.get('SELF_HOSTED', 'false').lower() == 'true'

# Payment provider configuration (only for hosted SaaS)
PAYMENT_PROVIDER = os.environ.get('PAYMENT_PROVIDER', '')  # 'ryft', etc.
PAYMENT_API_KEY = os.environ.get('PAYMENT_API_KEY', '')
PAYMENT_WEBHOOK_SECRET = os.environ.get('PAYMENT_WEBHOOK_SECRET', '')

# Default tier for new users (only applicable when not self-hosted)
DEFAULT_ACCOUNT_TIER = 'free'

8. Admin Interface

Location: checktick_app/core/admin.py

from django.contrib import admin
from .models import SiteBranding, UserProfile


@admin.register(SiteBranding)
class SiteBrandingAdmin(admin.ModelAdmin):
    list_display = ['default_theme', 'theme_preset_light', 'theme_preset_dark', 'updated_at']
    fieldsets = (
        ('Theme Configuration', {
            'fields': ('default_theme', 'theme_preset_light', 'theme_preset_dark')
        }),
        ('Logo/Icon', {
            'fields': ('icon_file', 'icon_url', 'icon_file_dark', 'icon_url_dark')
        }),
        ('Typography', {
            'fields': ('font_heading', 'font_body', 'font_css_url')
        }),
        ('Custom CSS', {
            'fields': ('theme_light_css', 'theme_dark_css'),
            'classes': ('collapse',)
        }),
    )


@admin.register(UserProfile)
class UserProfileAdmin(admin.ModelAdmin):
    list_display = ['user', 'account_tier', 'subscription_status', 'payment_provider', 'created_at']
    list_filter = ['account_tier', 'subscription_status', 'payment_provider']
    search_fields = ['user__username', 'user__email', 'payment_customer_id']
    readonly_fields = ['created_at', 'updated_at']

    fieldsets = (
        ('User Information', {
            'fields': ('user', 'account_tier')
        }),
        ('Subscription', {
            'fields': ('payment_provider', 'payment_customer_id', 'payment_subscription_id',
                      'subscription_status', 'subscription_period_end')
        }),
        ('Enterprise Branding', {
            'fields': ('custom_brand_name', 'custom_logo_file', 'custom_logo_url'),
            'classes': ('collapse',)
        }),
        ('Timestamps', {
            'fields': ('created_at', 'updated_at'),
            'classes': ('collapse',)
        }),
    )

URL Configuration

Location: checktick_app/core/urls.py

from django.urls import path
from . import views

app_name = 'core'

urlpatterns = [
    # ... existing URLs
    path('branding/', views.configure_branding, name='configure_branding'),
]

Templates

Branding Configuration Template

Location: checktick_app/templates/core/configure_branding.html

{% extends 'base.html' %}
{% load i18n %}

{% block title %}Configure Branding{% endblock %}

{% block content %}
<div class="container mx-auto px-4 py-8 max-w-4xl">
  <h1 class="text-3xl font-bold mb-6">Site Branding Configuration</h1>

  <div class="alert alert-info mb-6">
    <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" class="stroke-current shrink-0 w-6 h-6"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path></svg>
    <span>Customize your CheckTick instance branding. Changes will apply site-wide.</span>
  </div>

  <form method="post" enctype="multipart/form-data" class="space-y-6">
    {% csrf_token %}

    <div class="card bg-base-200">
      <div class="card-body">
        <h2 class="card-title">Theme Settings</h2>

        <div class="form-control">
          <label class="label">
            <span class="label-text">Default Theme</span>
          </label>
          {{ form.default_theme }}
        </div>

        <div class="form-control">
          <label class="label">
            <span class="label-text">Light Theme Preset</span>
            <span class="label-text-alt">DaisyUI preset (e.g., nord, cupcake)</span>
          </label>
          {{ form.theme_preset_light }}
        </div>

        <div class="form-control">
          <label class="label">
            <span class="label-text">Dark Theme Preset</span>
            <span class="label-text-alt">DaisyUI preset (e.g., business, dark)</span>
          </label>
          {{ form.theme_preset_dark }}
        </div>
      </div>
    </div>

    <div class="card bg-base-200">
      <div class="card-body">
        <h2 class="card-title">Logo & Icons</h2>

        <div class="form-control">
          <label class="label">
            <span class="label-text">Logo File (Light Mode)</span>
          </label>
          {{ form.icon_file }}
          {% if branding.icon_file %}
            <div class="mt-2">
              <img src="{{ branding.icon_file.url }}" alt="Current logo" class="h-12">
            </div>
          {% endif %}
        </div>

        <div class="form-control">
          <label class="label">
            <span class="label-text">Logo File (Dark Mode)</span>
          </label>
          {{ form.icon_file_dark }}
          {% if branding.icon_file_dark %}
            <div class="mt-2">
              <img src="{{ branding.icon_file_dark.url }}" alt="Current dark logo" class="h-12">
            </div>
          {% endif %}
        </div>

        <div class="form-control">
          <label class="label">
            <span class="label-text">Logo URL (Light Mode)</span>
            <span class="label-text-alt">Alternative to file upload</span>
          </label>
          {{ form.icon_url }}
        </div>

        <div class="form-control">
          <label class="label">
            <span class="label-text">Logo URL (Dark Mode)</span>
          </label>
          {{ form.icon_url_dark }}
        </div>
      </div>
    </div>

    <div class="card bg-base-200">
      <div class="card-body">
        <h2 class="card-title">Typography</h2>

        <div class="form-control">
          <label class="label">
            <span class="label-text">Heading Font</span>
          </label>
          {{ form.font_heading }}
        </div>

        <div class="form-control">
          <label class="label">
            <span class="label-text">Body Font</span>
          </label>
          {{ form.font_body }}
        </div>

        <div class="form-control">
          <label class="label">
            <span class="label-text">Font CSS URL</span>
            <span class="label-text-alt">Google Fonts or custom CSS</span>
          </label>
          {{ form.font_css_url }}
        </div>
      </div>
    </div>

    <div class="flex gap-4">
      <button type="submit" class="btn btn-primary">Save Changes</button>
      <a href="{% url 'surveys:list' %}" class="btn btn-ghost">Cancel</a>
    </div>
  </form>
</div>
{% endblock %}

Testing Strategy

Unit Tests

Location: checktick_app/core/tests/test_user_profile.py

from django.test import TestCase
from django.contrib.auth import get_user_model
from django.conf import settings

from checktick_app.core.models import UserProfile
from checktick_app.surveys.models import Survey

User = get_user_model()


class UserProfileTestCase(TestCase):
    def setUp(self):
        self.user = User.objects.create_user(
            username='testuser',
            email='[email protected]',
            password='testpass123'
        )

    def test_profile_created_automatically(self):
        """Test that UserProfile is created automatically with User."""
        self.assertTrue(hasattr(self.user, 'profile'))
        self.assertEqual(self.user.profile.account_tier, UserProfile.AccountTier.FREE)

    def test_self_hosted_gets_enterprise_tier(self):
        """Test that self-hosted instances get Enterprise features."""
        with self.settings(SELF_HOSTED=True):
            effective_tier = self.user.profile.get_effective_tier()
            self.assertEqual(effective_tier, UserProfile.AccountTier.ENTERPRISE)

    def test_free_tier_survey_limit(self):
        """Test that FREE tier is limited to 3 surveys."""
        with self.settings(SELF_HOSTED=False):
            # Create 3 surveys
            for i in range(3):
                Survey.objects.create(
                    owner=self.user,
                    name=f'Survey {i}',
                    slug=f'survey-{i}'
                )

            # Should not be able to create 4th survey
            self.assertFalse(self.user.profile.can_create_survey())

    def test_pro_tier_unlimited_surveys(self):
        """Test that PRO tier has unlimited surveys."""
        self.user.profile.account_tier = UserProfile.AccountTier.PRO
        self.user.profile.save()

        with self.settings(SELF_HOSTED=False):
            # Create many surveys
            for i in range(10):
                Survey.objects.create(
                    owner=self.user,
                    name=f'Survey {i}',
                    slug=f'survey-{i}'
                )

            # Should still be able to create more
            self.assertTrue(self.user.profile.can_create_survey())

    def test_free_tier_no_collaborators(self):
        """Test that FREE tier cannot add collaborators."""
        with self.settings(SELF_HOSTED=False):
            self.assertFalse(self.user.profile.can_add_collaborators())

    def test_pro_tier_can_add_collaborators(self):
        """Test that PRO tier can add collaborators."""
        self.user.profile.account_tier = UserProfile.AccountTier.PRO
        self.user.profile.save()

        with self.settings(SELF_HOSTED=False):
            self.assertTrue(self.user.profile.can_add_collaborators())

    def test_pro_tier_cannot_add_viewers(self):
        """Test that PRO tier cannot add VIEWER role."""
        self.user.profile.account_tier = UserProfile.AccountTier.PRO
        self.user.profile.save()

        with self.settings(SELF_HOSTED=False):
            self.assertFalse(self.user.profile.can_add_viewers())

    def test_enterprise_branding_access(self):
        """Test that only Enterprise tier can customize branding."""
        with self.settings(SELF_HOSTED=False):
            # FREE tier cannot customize
            self.assertFalse(self.user.profile.can_customize_branding())

            # Enterprise can customize
            self.user.profile.account_tier = UserProfile.AccountTier.ENTERPRISE
            self.user.profile.save()
            self.assertTrue(self.user.profile.can_customize_branding())

    def test_self_hosted_superuser_can_brand(self):
        """Test that superusers in self-hosted can customize branding."""
        self.user.is_superuser = True
        self.user.save()

        with self.settings(SELF_HOSTED=True):
            self.assertTrue(self.user.profile.can_customize_branding())

Migration Path

For Existing Deployments

  1. Run migration to create UserProfile table and profiles for existing users
  2. All existing users default to FREE tier
  3. Self-hosted instances: Set SELF_HOSTED=true in environment
  4. Hosted instances: Manual upgrade of specific users to PRO/ORGANIZATION/ENTERPRISE as needed

Commands to Run

# Generate migration
python manage.py makemigrations

# Apply migration
python manage.py migrate

# Configure branding (self-hosted)
python manage.py configure_branding --theme-light=nord --theme-dark=business --logo=path/to/logo.png

# Or use Django admin at /admin/core/sitebranding/

API Endpoints (Future)

For hosted SaaS, we'll need API endpoints for:

  1. Check tier status: GET /api/account/tier/
  2. Upgrade tier: POST /api/account/upgrade/
  3. Payment webhook: POST /api/webhooks/payment/
  4. Usage stats: GET /api/account/usage/

These will be implemented in a separate phase after the core tier system is in place.


Documentation Updates Required

  1. Getting Started: Update account types comparison
  2. Self-Hosting Guide: Document SELF_HOSTED setting and branding configuration
  3. API Documentation: Document tier-specific rate limits
  4. User Guide: Explain tier features and upgrade paths
  5. Admin Guide: Document branding configuration UI

Managing Account Tiers

Django Admin Interface

Account tiers are currently managed through the Django admin interface. This approach is suitable for:

  • Self-hosted deployments
  • Beta testing and early access programs
  • Manual tier assignments before payment integration

Upgrading Users via Admin

  1. Access Admin Interface
  2. Navigate to /admin/ and log in with superuser credentials
  3. Go to Core > User Profiles

  4. Find User Profile

  5. Use the search box to find users by username or email
  6. Or filter by current tier using the right sidebar

  7. Change Account Tier

  8. Click on the user's profile
  9. In the "Account Tier" section, select the desired tier from the dropdown:
    • Individual (Free) - Default tier, 3 surveys max, no collaboration
    • Individual Pro - Unlimited surveys, can add editors (10 max per survey)
    • Organization - Full collaboration features, unlimited collaborators, viewer role
    • Enterprise - All Organization features plus custom branding and SSO
  10. Click "Save"

  11. Verify Changes

  12. The tier_changed_at timestamp is automatically updated
  13. User immediately gains access to new tier features
  14. No logout/login required - changes are applied on next page load

Bulk Tier Changes

For updating multiple users at once:

  1. Select users in the User Profiles list using checkboxes
  2. Choose "Change account tier" from the "Action" dropdown (if configured)
  3. Or use Django shell for bulk updates:
from checktick_app.core.models import UserProfile

# Upgrade all users in a specific organization
org_users = UserProfile.objects.filter(user__organization_memberships__organization__name="Example Org")
org_users.update(account_tier=UserProfile.AccountTier.ORGANIZATION)

# Upgrade a specific user
profile = UserProfile.objects.get(user__username="johndoe")
profile.account_tier = UserProfile.AccountTier.PRO
profile.save()

Monitoring Tier Usage

The admin interface provides filtering and search capabilities:

  • Filter by tier: Use the right sidebar to see all users in each tier
  • Filter by subscription status: Track active/inactive/cancelled subscriptions
  • Search: Find users by username, email, or payment IDs
  • List view shows:
  • Username
  • Current account tier
  • Subscription status
  • Payment provider
  • Last tier change timestamp

Self-Hosted Mode

For self-hosted deployments, set SELF_HOSTED=true in your environment:

  • All users automatically receive Enterprise tier features
  • No admin intervention needed
  • Custom branding available to all users
  • See Self-Hosting Documentation for full details

Future: Payment Integration

Payment integration (Stripe, Ryft, etc.) will be added in a future update. The current schema already includes all necessary fields:

  • payment_provider
  • payment_customer_id
  • payment_subscription_id
  • subscription_status
  • subscription_current_period_end

These fields are ready for webhook handlers and automated tier management when payment is integrated.


Summary

This implementation provides:

โœ… Flexible tier system (FREE, PRO, ORGANIZATION, ENTERPRISE) โœ… Self-hosted support with full Enterprise features โœ… Generic payment integration (Ryft or others) โœ… UI-based branding configuration (no .env editing) โœ… Management command for CLI configuration โœ… Automatic profile creation for all users โœ… Backward compatible with existing deployments โœ… Comprehensive feature matrix with all current features โœ… Security-first approach (all tiers get encryption & audit logging) โœ… Admin-managed tier upgrades until payment integration

The system is designed to be:

  • Self-hosting friendly: No payment barriers, full features
  • SaaS ready: Payment integration, tier enforcement
  • User-friendly: UI configuration instead of environment variables
  • Healthcare compliant: Security features available to all tiers
  • Admin-manageable: Simple tier upgrades through Django admin