Fix subscription limits; seed Free/Good/Better/Best default plans

Agent-Logs-Url: https://github.com/christianlouis/pop_puller_to_gmail/sessions/98c1f90b-0287-4908-a0be-ad066c453cc5

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
This commit is contained in:
copilot-swe-agent[bot]
2026-03-26 16:00:56 +00:00
parent 979b7b6cec
commit 7971d3f76a
5 changed files with 158 additions and 28 deletions
+5
View File
@@ -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.
+40 -20
View File
@@ -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)
+13 -1
View File
@@ -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,
}
+84 -1
View File
@@ -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
+16 -6
View File
@@ -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<SubscriptionPlanCreate>(
@@ -204,7 +206,15 @@ function PlanFormModal({
Cancel
</button>
<button
onClick={() => onSave(form)}
onClick={() => {
if (isEdit && onUpdate) {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { tier: _tier, ...updateFields } = form;
onUpdate(updateFields);
} else if (!isEdit && onCreate) {
onCreate(form);
}
}}
className="px-4 py-2 text-sm font-medium text-white bg-purple-600 rounded-md hover:bg-purple-700"
>
{isEdit ? 'Save Changes' : 'Create Plan'}
@@ -395,15 +405,15 @@ export default function AdminPlansPage() {
<PlanFormModal
plan={null}
onClose={() => setShowCreate(false)}
onSave={(data) => createMutation.mutate(data as SubscriptionPlanCreate)}
onCreate={(data) => createMutation.mutate(data)}
/>
)}
{editingPlan && (
<PlanFormModal
plan={editingPlan}
onClose={() => setEditingPlan(null)}
onSave={(data) =>
updateMutation.mutate({ id: editingPlan.id, data: data as SubscriptionPlanUpdate })
onUpdate={(data) =>
updateMutation.mutate({ id: editingPlan.id, data })
}
/>
)}