diff --git a/CHANGELOG.md b/CHANGELOG.md index 2d60da3..6ea34d3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed - Test email sender name corrected from "Christian Loris" to "Christian Krakau-Louis". +- **Mailbox limit always hit at 1**: The `subscription_plans` table was never seeded, so the limit check fell back to the env-var default of `TIER_FREE_MAX_ACCOUNTS=1` for every user regardless of their tier. Fixed by: + 1. Seeding four default `SubscriptionPlan` rows at startup — **Free**, **Good**, **Better**, **Best** — so admin-managed limits are stored in the DB from first boot. + 2. Rewriting the limit check to look up the user's active plan from the DB first, falling back to env-var config only when no plan row exists. + 3. Bypassing the limit entirely for superusers (admins can always add mailboxes). + 4. Adding plan limit fields (`max_mail_accounts`, `max_emails_per_day`, `check_interval_minutes`) to the `GET /subscriptions/current` response so the frontend can display them. ### Fixed - Fixed `TypeError: can't subtract offset-naive and offset-aware datetimes` in `process_mail_account` task when computing `duration_seconds`. After a database refresh, `started_at` may be returned as a naive datetime; it is now normalized to UTC before subtraction. diff --git a/backend/app/api/v1/endpoints/mail_accounts.py b/backend/app/api/v1/endpoints/mail_accounts.py index 196b142..8e2d5b1 100644 --- a/backend/app/api/v1/endpoints/mail_accounts.py +++ b/backend/app/api/v1/endpoints/mail_accounts.py @@ -8,7 +8,12 @@ from sqlalchemy import select, desc from app.core.database import get_db from app.core.deps import get_current_active_user from app.core.security import encrypt_credential -from app.models.database_models import User, MailAccount, AccountStatus +from app.models.database_models import ( + User, + MailAccount, + AccountStatus, + SubscriptionPlan, +) from app.models.schemas import ( MailAccountCreate, MailAccountResponse, @@ -34,26 +39,41 @@ async def create_mail_account( ): """Create a new mail account""" - # Check subscription limits - result = await db.execute( - select(MailAccount).where(MailAccount.user_id == current_user.id) - ) - existing_accounts = result.scalars().all() - - tier_limits = { - "free": settings.TIER_FREE_MAX_ACCOUNTS, - "basic": settings.TIER_BASIC_MAX_ACCOUNTS, - "pro": settings.TIER_PRO_MAX_ACCOUNTS, - "enterprise": settings.TIER_ENTERPRISE_MAX_ACCOUNTS, - } - - max_accounts = tier_limits.get(current_user.subscription_tier.value, 1) - - if len(existing_accounts) >= max_accounts: - raise HTTPException( - status_code=status.HTTP_402_PAYMENT_REQUIRED, - detail="Account limit reached. Upgrade your subscription to add more accounts.", + # Superusers are not subject to subscription limits + if not current_user.is_superuser: + # Count existing accounts for this user + result = await db.execute( + select(MailAccount).where(MailAccount.user_id == current_user.id) ) + existing_accounts = result.scalars().all() + + # Try to look up the limit from the active SubscriptionPlan in the DB first + # so that admin-managed plan limits take effect immediately. + plan_result = await db.execute( + select(SubscriptionPlan).where( + SubscriptionPlan.tier == current_user.subscription_tier, + SubscriptionPlan.is_active.is_(True), + ) + ) + plan = plan_result.scalar_one_or_none() + + if plan is not None: + max_accounts = plan.max_mail_accounts + else: + # Fall back to env-var / config values when no plan row exists + tier_limits = { + "free": settings.TIER_FREE_MAX_ACCOUNTS, + "basic": settings.TIER_BASIC_MAX_ACCOUNTS, + "pro": settings.TIER_PRO_MAX_ACCOUNTS, + "enterprise": settings.TIER_ENTERPRISE_MAX_ACCOUNTS, + } + max_accounts = tier_limits.get(current_user.subscription_tier.value, 1) + + if len(existing_accounts) >= max_accounts: + raise HTTPException( + status_code=status.HTTP_402_PAYMENT_REQUIRED, + detail="Account limit reached. Upgrade your subscription to add more accounts.", + ) # Encrypt password encrypted_password = encrypt_credential(account_in.password) diff --git a/backend/app/api/v1/endpoints/subscriptions.py b/backend/app/api/v1/endpoints/subscriptions.py index d25f02f..8209f25 100644 --- a/backend/app/api/v1/endpoints/subscriptions.py +++ b/backend/app/api/v1/endpoints/subscriptions.py @@ -25,10 +25,22 @@ async def list_subscription_plans(db: AsyncSession = Depends(get_db)): @router.get("/current") async def get_current_subscription( current_user: User = Depends(get_current_active_user), + db: AsyncSession = Depends(get_db), ): - """Get current user's subscription details""" + """Get current user's subscription details including plan limits""" + plan_result = await db.execute( + select(SubscriptionPlan).where( + SubscriptionPlan.tier == current_user.subscription_tier, + SubscriptionPlan.is_active.is_(True), + ) + ) + plan = plan_result.scalar_one_or_none() + return { "tier": current_user.subscription_tier, "status": current_user.subscription_status, "expires_at": current_user.subscription_expires_at, + "max_mail_accounts": plan.max_mail_accounts if plan else None, + "max_emails_per_day": plan.max_emails_per_day if plan else None, + "check_interval_minutes": plan.check_interval_minutes if plan else None, } diff --git a/backend/app/services/config_service.py b/backend/app/services/config_service.py index 142a997..699836e 100644 --- a/backend/app/services/config_service.py +++ b/backend/app/services/config_service.py @@ -20,7 +20,7 @@ from typing import Any, Dict, List, Optional from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession -from app.models.database_models import AppSetting +from app.models.database_models import AppSetting, SubscriptionPlan, SubscriptionTier logger = logging.getLogger(__name__) @@ -338,4 +338,87 @@ class ConfigService: if created: await db.commit() logger.info("Seeded %d default settings into the database", created) + + # Seed default subscription plans + await ConfigService.seed_default_plans(db) + + return created + + @staticmethod + async def seed_default_plans(db: AsyncSession) -> int: + """ + Populate the database with default subscription plans (skip existing tiers). + + Limits mirror the env-var defaults so behaviour is unchanged on first boot + but can be overridden by admins via the plan management UI. + + Returns the number of plans created. + """ + from app.core.config import settings # local import to avoid circular deps + + default_plans = [ + { + "tier": SubscriptionTier.FREE, + "name": "Free", + "description": "Try it out – one mailbox, no credit card required.", + "price_monthly": 0.0, + "price_yearly": 0.0, + "max_mail_accounts": settings.TIER_FREE_MAX_ACCOUNTS, + "max_emails_per_day": 100, + "check_interval_minutes": 30, + "support_level": "community", + "is_active": True, + }, + { + "tier": SubscriptionTier.BASIC, + "name": "Good", + "description": "Great for personal use – a handful of mailboxes checked regularly.", + "price_monthly": 4.99, + "price_yearly": 49.90, + "max_mail_accounts": settings.TIER_BASIC_MAX_ACCOUNTS, + "max_emails_per_day": 1000, + "check_interval_minutes": 15, + "support_level": "email", + "is_active": True, + }, + { + "tier": SubscriptionTier.PRO, + "name": "Better", + "description": "Power users and small teams – more mailboxes, faster checks.", + "price_monthly": 12.99, + "price_yearly": 129.90, + "max_mail_accounts": settings.TIER_PRO_MAX_ACCOUNTS, + "max_emails_per_day": 10000, + "check_interval_minutes": 5, + "support_level": "email", + "is_active": True, + }, + { + "tier": SubscriptionTier.ENTERPRISE, + "name": "Best", + "description": "Organisations and heavy workloads – maximum mailboxes, near-real-time checks.", + "price_monthly": 29.99, + "price_yearly": 299.90, + "max_mail_accounts": settings.TIER_ENTERPRISE_MAX_ACCOUNTS, + "max_emails_per_day": 100000, + "check_interval_minutes": 1, + "support_level": "priority", + "is_active": True, + }, + ] + + created = 0 + for plan_data in default_plans: + result = await db.execute( + select(SubscriptionPlan).where( + SubscriptionPlan.tier == plan_data["tier"] + ) + ) + if result.scalar_one_or_none() is None: + db.add(SubscriptionPlan(**plan_data)) + created += 1 + + if created: + await db.commit() + logger.info("Seeded %d default subscription plans into the database", created) return created diff --git a/frontend/src/app/admin/plans/page.tsx b/frontend/src/app/admin/plans/page.tsx index db9f103..4e4b208 100644 --- a/frontend/src/app/admin/plans/page.tsx +++ b/frontend/src/app/admin/plans/page.tsx @@ -32,11 +32,13 @@ const DEFAULT_FORM: SubscriptionPlanCreate = { function PlanFormModal({ plan, onClose, - onSave, + onCreate, + onUpdate, }: { plan: SubscriptionPlan | null; onClose: () => void; - onSave: (data: SubscriptionPlanCreate | SubscriptionPlanUpdate) => void; + onCreate?: (data: SubscriptionPlanCreate) => void; + onUpdate?: (data: SubscriptionPlanUpdate) => void; }) { const isEdit = plan !== null; const [form, setForm] = useState( @@ -204,7 +206,15 @@ function PlanFormModal({ Cancel