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:
@@ -27,6 +27,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
- Test email sender name corrected from "Christian Loris" to "Christian Krakau-Louis".
|
- 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
|
||||||
- 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.
|
- 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.
|
||||||
|
|||||||
@@ -8,7 +8,12 @@ from sqlalchemy import select, desc
|
|||||||
from app.core.database import get_db
|
from app.core.database import get_db
|
||||||
from app.core.deps import get_current_active_user
|
from app.core.deps import get_current_active_user
|
||||||
from app.core.security import encrypt_credential
|
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 (
|
from app.models.schemas import (
|
||||||
MailAccountCreate,
|
MailAccountCreate,
|
||||||
MailAccountResponse,
|
MailAccountResponse,
|
||||||
@@ -34,19 +39,34 @@ async def create_mail_account(
|
|||||||
):
|
):
|
||||||
"""Create a new mail account"""
|
"""Create a new mail account"""
|
||||||
|
|
||||||
# Check subscription limits
|
# Superusers are not subject to subscription limits
|
||||||
|
if not current_user.is_superuser:
|
||||||
|
# Count existing accounts for this user
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
select(MailAccount).where(MailAccount.user_id == current_user.id)
|
select(MailAccount).where(MailAccount.user_id == current_user.id)
|
||||||
)
|
)
|
||||||
existing_accounts = result.scalars().all()
|
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 = {
|
tier_limits = {
|
||||||
"free": settings.TIER_FREE_MAX_ACCOUNTS,
|
"free": settings.TIER_FREE_MAX_ACCOUNTS,
|
||||||
"basic": settings.TIER_BASIC_MAX_ACCOUNTS,
|
"basic": settings.TIER_BASIC_MAX_ACCOUNTS,
|
||||||
"pro": settings.TIER_PRO_MAX_ACCOUNTS,
|
"pro": settings.TIER_PRO_MAX_ACCOUNTS,
|
||||||
"enterprise": settings.TIER_ENTERPRISE_MAX_ACCOUNTS,
|
"enterprise": settings.TIER_ENTERPRISE_MAX_ACCOUNTS,
|
||||||
}
|
}
|
||||||
|
|
||||||
max_accounts = tier_limits.get(current_user.subscription_tier.value, 1)
|
max_accounts = tier_limits.get(current_user.subscription_tier.value, 1)
|
||||||
|
|
||||||
if len(existing_accounts) >= max_accounts:
|
if len(existing_accounts) >= max_accounts:
|
||||||
|
|||||||
@@ -25,10 +25,22 @@ async def list_subscription_plans(db: AsyncSession = Depends(get_db)):
|
|||||||
@router.get("/current")
|
@router.get("/current")
|
||||||
async def get_current_subscription(
|
async def get_current_subscription(
|
||||||
current_user: User = Depends(get_current_active_user),
|
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 {
|
return {
|
||||||
"tier": current_user.subscription_tier,
|
"tier": current_user.subscription_tier,
|
||||||
"status": current_user.subscription_status,
|
"status": current_user.subscription_status,
|
||||||
"expires_at": current_user.subscription_expires_at,
|
"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,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ from typing import Any, Dict, List, Optional
|
|||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
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__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -338,4 +338,87 @@ class ConfigService:
|
|||||||
if created:
|
if created:
|
||||||
await db.commit()
|
await db.commit()
|
||||||
logger.info("Seeded %d default settings into the database", created)
|
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
|
return created
|
||||||
|
|||||||
@@ -32,11 +32,13 @@ const DEFAULT_FORM: SubscriptionPlanCreate = {
|
|||||||
function PlanFormModal({
|
function PlanFormModal({
|
||||||
plan,
|
plan,
|
||||||
onClose,
|
onClose,
|
||||||
onSave,
|
onCreate,
|
||||||
|
onUpdate,
|
||||||
}: {
|
}: {
|
||||||
plan: SubscriptionPlan | null;
|
plan: SubscriptionPlan | null;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onSave: (data: SubscriptionPlanCreate | SubscriptionPlanUpdate) => void;
|
onCreate?: (data: SubscriptionPlanCreate) => void;
|
||||||
|
onUpdate?: (data: SubscriptionPlanUpdate) => void;
|
||||||
}) {
|
}) {
|
||||||
const isEdit = plan !== null;
|
const isEdit = plan !== null;
|
||||||
const [form, setForm] = useState<SubscriptionPlanCreate>(
|
const [form, setForm] = useState<SubscriptionPlanCreate>(
|
||||||
@@ -204,7 +206,15 @@ function PlanFormModal({
|
|||||||
Cancel
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
<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"
|
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'}
|
{isEdit ? 'Save Changes' : 'Create Plan'}
|
||||||
@@ -395,15 +405,15 @@ export default function AdminPlansPage() {
|
|||||||
<PlanFormModal
|
<PlanFormModal
|
||||||
plan={null}
|
plan={null}
|
||||||
onClose={() => setShowCreate(false)}
|
onClose={() => setShowCreate(false)}
|
||||||
onSave={(data) => createMutation.mutate(data as SubscriptionPlanCreate)}
|
onCreate={(data) => createMutation.mutate(data)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{editingPlan && (
|
{editingPlan && (
|
||||||
<PlanFormModal
|
<PlanFormModal
|
||||||
plan={editingPlan}
|
plan={editingPlan}
|
||||||
onClose={() => setEditingPlan(null)}
|
onClose={() => setEditingPlan(null)}
|
||||||
onSave={(data) =>
|
onUpdate={(data) =>
|
||||||
updateMutation.mutate({ id: editingPlan.id, data: data as SubscriptionPlanUpdate })
|
updateMutation.mutate({ id: editingPlan.id, data })
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
Reference in New Issue
Block a user