chore: merge main into notifications branch
Resolve conflicts in app/auth.py and app/api/admin_users.py: - auth.py: combine admin-aware profile creation (from main, adding is_complimentary/highest-tier defaults for admins) with signup notification/webhook (from our branch). Admin users skip the signup notification since they are the ones being notified. - admin_users.py: combine is_complimentary assignment (from main) with tier_changed/new_tier tracking variables (from our branch) to fire plan-change notifications when an admin updates a user.
This commit is contained in:
+143
-1
@@ -2,6 +2,8 @@
|
||||
|
||||
Provides CRUD operations for user profiles and aggregate statistics so that
|
||||
administrators can inspect, configure, and manage users in multi-user mode.
|
||||
Also provides endpoints for admins to create and manage local (email/password)
|
||||
user accounts directly, without requiring email verification.
|
||||
"""
|
||||
|
||||
import logging
|
||||
@@ -14,7 +16,8 @@ from sqlalchemy import func
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.database import get_db
|
||||
from app.models import FileRecord, UserProfile
|
||||
from app.models import FileRecord, LocalUser, UserProfile
|
||||
from app.utils.local_auth import hash_password
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/admin/users", tags=["admin-users"])
|
||||
@@ -59,6 +62,11 @@ class UserProfileUpsert(BaseModel):
|
||||
subscription_billing_cycle: str = Field(default="monthly", pattern="^(monthly|yearly)$")
|
||||
subscription_period_start: datetime | None = None
|
||||
allow_overage: bool = False
|
||||
is_complimentary: bool = Field(
|
||||
default=False,
|
||||
description="When True the user is on a complimentary (uncharged) plan — they keep all tier "
|
||||
"quota benefits but are never billed via Stripe.",
|
||||
)
|
||||
|
||||
|
||||
class PaymentIssueBody(BaseModel):
|
||||
@@ -80,6 +88,7 @@ class UserProfileResponse(BaseModel):
|
||||
subscription_billing_cycle: str
|
||||
subscription_period_start: str | None
|
||||
allow_overage: bool
|
||||
is_complimentary: bool
|
||||
created_at: str | None
|
||||
updated_at: str | None
|
||||
|
||||
@@ -98,11 +107,36 @@ class UserSummary(BaseModel):
|
||||
subscription_billing_cycle: str | None
|
||||
subscription_period_start: str | None
|
||||
allow_overage: bool
|
||||
is_complimentary: bool
|
||||
profile_id: int | None
|
||||
document_count: int
|
||||
last_upload: str | None
|
||||
|
||||
|
||||
class LocalUserCreate(BaseModel):
|
||||
"""Body for admin-creating a local (email/password) user account."""
|
||||
|
||||
email: str = Field(..., max_length=255, description="Email address for the new user")
|
||||
username: str = Field(..., min_length=3, max_length=64, pattern=r"^[a-zA-Z0-9_-]+$")
|
||||
display_name: str | None = Field(default=None, max_length=255)
|
||||
password: str = Field(..., min_length=8, max_length=128)
|
||||
is_admin: bool = Field(default=False, description="Grant admin privileges")
|
||||
|
||||
|
||||
class LocalUserResponse(BaseModel):
|
||||
"""Summary of a local user account."""
|
||||
|
||||
id: int
|
||||
email: str
|
||||
username: str
|
||||
display_name: str | None
|
||||
is_active: bool
|
||||
is_admin: bool
|
||||
created_at: str | None
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -127,6 +161,7 @@ def _profile_to_dict(profile: UserProfile) -> dict[str, Any]:
|
||||
if profile.subscription_period_start
|
||||
else None,
|
||||
"allow_overage": bool(profile.allow_overage),
|
||||
"is_complimentary": bool(profile.is_complimentary),
|
||||
"created_at": profile.created_at.isoformat() if profile.created_at else None,
|
||||
"updated_at": profile.updated_at.isoformat() if profile.updated_at else None,
|
||||
}
|
||||
@@ -200,6 +235,7 @@ def list_users(
|
||||
if (profile and profile.subscription_period_start)
|
||||
else None,
|
||||
"allow_overage": bool(profile.allow_overage) if profile else False,
|
||||
"is_complimentary": bool(profile.is_complimentary) if profile else False,
|
||||
"profile_id": profile.id if profile else None,
|
||||
"document_count": doc_row.doc_count if doc_row else 0,
|
||||
"last_upload": doc_row.last_upload.isoformat() if (doc_row and doc_row.last_upload) else None,
|
||||
@@ -215,6 +251,110 @@ def list_users(
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Local user management (admin-only)
|
||||
# ---------------------------------------------------------------------------
|
||||
# NOTE: These routes MUST be defined before /{user_id:path} to avoid being
|
||||
# swallowed by the catch-all path parameter.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.get("/local", summary="List all local (email/password) user accounts")
|
||||
def list_local_users(db: DbSession, _admin: AdminUser) -> list[dict[str, Any]]:
|
||||
"""Return every local user account with basic metadata."""
|
||||
users = db.query(LocalUser).order_by(LocalUser.created_at.desc()).all()
|
||||
return [
|
||||
{
|
||||
"id": u.id,
|
||||
"email": u.email,
|
||||
"username": u.username,
|
||||
"display_name": u.display_name,
|
||||
"is_active": u.is_active,
|
||||
"is_admin": u.is_admin,
|
||||
"created_at": u.created_at.isoformat() if u.created_at else None,
|
||||
}
|
||||
for u in users
|
||||
]
|
||||
|
||||
|
||||
@router.post("/local", status_code=status.HTTP_201_CREATED, summary="Create a local user account")
|
||||
def create_local_user(body: LocalUserCreate, db: DbSession, _admin: AdminUser) -> dict[str, Any]:
|
||||
"""Create a new local (email/password) user account.
|
||||
|
||||
The account is immediately active — no email verification is required when
|
||||
created by an administrator. A matching UserProfile row is also created.
|
||||
|
||||
Raises:
|
||||
409: Email or username already registered.
|
||||
"""
|
||||
if db.query(LocalUser).filter(LocalUser.email == body.email).first():
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Email already registered.")
|
||||
if db.query(LocalUser).filter(LocalUser.username == body.username).first():
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Username already taken.")
|
||||
|
||||
user = LocalUser(
|
||||
email=body.email,
|
||||
username=body.username,
|
||||
display_name=body.display_name,
|
||||
hashed_password=hash_password(body.password),
|
||||
is_active=True,
|
||||
is_admin=body.is_admin,
|
||||
)
|
||||
db.add(user)
|
||||
|
||||
# Ensure a UserProfile exists for the new user
|
||||
if not db.query(UserProfile).filter(UserProfile.user_id == body.email).first():
|
||||
db.add(UserProfile(user_id=body.email, display_name=body.display_name or body.username))
|
||||
|
||||
try:
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
except Exception:
|
||||
db.rollback()
|
||||
raise
|
||||
|
||||
logger.info("Admin created local user account: %s", body.email)
|
||||
return {
|
||||
"id": user.id,
|
||||
"email": user.email,
|
||||
"username": user.username,
|
||||
"display_name": user.display_name,
|
||||
"is_active": user.is_active,
|
||||
"is_admin": user.is_admin,
|
||||
"created_at": user.created_at.isoformat() if user.created_at else None,
|
||||
}
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/local/{local_user_id}",
|
||||
status_code=status.HTTP_204_NO_CONTENT,
|
||||
summary="Delete a local user account",
|
||||
)
|
||||
def delete_local_user(local_user_id: int, db: DbSession, _admin: AdminUser) -> None:
|
||||
"""Delete a local user account by its numeric ID.
|
||||
|
||||
The associated UserProfile is also removed. Documents owned by this user
|
||||
are **not** deleted.
|
||||
"""
|
||||
user = db.query(LocalUser).filter(LocalUser.id == local_user_id).first()
|
||||
if not user:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Local user not found.")
|
||||
|
||||
# Remove associated profile if present
|
||||
profile = db.query(UserProfile).filter(UserProfile.user_id == user.email).first()
|
||||
if profile:
|
||||
db.delete(profile)
|
||||
|
||||
try:
|
||||
db.delete(user)
|
||||
db.commit()
|
||||
except Exception:
|
||||
db.rollback()
|
||||
raise
|
||||
|
||||
logger.info("Admin deleted local user account: %s", user.email)
|
||||
|
||||
|
||||
@router.get("/{user_id:path}", summary="Get details for a single user")
|
||||
def get_user(user_id: str, db: DbSession, _admin: AdminUser) -> dict[str, Any]:
|
||||
"""Return profile and document statistics for a specific user."""
|
||||
@@ -241,6 +381,7 @@ def get_user(user_id: str, db: DbSession, _admin: AdminUser) -> dict[str, Any]:
|
||||
if (profile and profile.subscription_period_start)
|
||||
else None,
|
||||
"allow_overage": bool(profile.allow_overage) if profile else False,
|
||||
"is_complimentary": bool(profile.is_complimentary) if profile else False,
|
||||
"profile_id": profile.id if profile else None,
|
||||
"document_count": doc_count,
|
||||
"last_upload": last_upload,
|
||||
@@ -272,6 +413,7 @@ def upsert_user_profile(
|
||||
profile.subscription_billing_cycle = body.subscription_billing_cycle
|
||||
profile.subscription_period_start = body.subscription_period_start
|
||||
profile.allow_overage = body.allow_overage
|
||||
profile.is_complimentary = body.is_complimentary
|
||||
tier_changed = False
|
||||
new_tier: str | None = None
|
||||
if body.subscription_tier is not None:
|
||||
|
||||
+50
-32
@@ -128,15 +128,18 @@ async def reset_password_page(request: Request) -> Any:
|
||||
|
||||
|
||||
@router.post("/api/auth/signup", status_code=status.HTTP_201_CREATED)
|
||||
async def signup(request: Request, body: SignupBody, db: DbSession) -> dict[str, str]:
|
||||
"""Create a new local user account and send a verification email.
|
||||
async def signup(request: Request, body: SignupBody, db: DbSession) -> dict[str, str | bool]:
|
||||
"""Create a new local user account.
|
||||
|
||||
When SMTP is configured the account is inactive until the user clicks the
|
||||
verification link sent to their email. When SMTP is **not** configured the
|
||||
account is activated immediately so that deployments without email can still
|
||||
use the self-registration flow.
|
||||
|
||||
The account is inactive until the user clicks the email link.
|
||||
Both ``MULTI_USER_ENABLED`` and ``ALLOW_LOCAL_SIGNUP`` must be ``True``.
|
||||
|
||||
Raises:
|
||||
403: Multi-user mode or local signup is disabled.
|
||||
503: SMTP is not configured.
|
||||
422: Passwords do not match.
|
||||
409: Email or username already registered.
|
||||
"""
|
||||
@@ -144,11 +147,6 @@ async def signup(request: Request, body: SignupBody, db: DbSession) -> dict[str,
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Multi-user mode is not enabled.")
|
||||
if not settings.allow_local_signup:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Registration is not enabled.")
|
||||
if not settings.email_host:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail="Email (SMTP) must be configured before local signup can be enabled.",
|
||||
)
|
||||
if body.password != body.password_confirm:
|
||||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="Passwords do not match.")
|
||||
|
||||
@@ -157,16 +155,30 @@ async def signup(request: Request, body: SignupBody, db: DbSession) -> dict[str,
|
||||
if db.query(LocalUser).filter(LocalUser.username == body.username).first():
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Username already taken.")
|
||||
|
||||
token = generate_token()
|
||||
user = LocalUser(
|
||||
email=body.email,
|
||||
username=body.username,
|
||||
display_name=body.display_name,
|
||||
hashed_password=hash_password(body.password),
|
||||
is_active=False,
|
||||
email_verification_token=token,
|
||||
email_verification_sent_at=datetime.now(tz=timezone.utc),
|
||||
)
|
||||
smtp_configured = bool(settings.email_host)
|
||||
|
||||
if smtp_configured:
|
||||
token = generate_token()
|
||||
user = LocalUser(
|
||||
email=body.email,
|
||||
username=body.username,
|
||||
display_name=body.display_name,
|
||||
hashed_password=hash_password(body.password),
|
||||
is_active=False,
|
||||
email_verification_token=token,
|
||||
email_verification_sent_at=datetime.now(tz=timezone.utc),
|
||||
)
|
||||
else:
|
||||
# No SMTP configured — activate the account immediately.
|
||||
token = None
|
||||
user = LocalUser(
|
||||
email=body.email,
|
||||
username=body.username,
|
||||
display_name=body.display_name,
|
||||
hashed_password=hash_password(body.password),
|
||||
is_active=True,
|
||||
)
|
||||
|
||||
db.add(user)
|
||||
|
||||
profile = UserProfile(
|
||||
@@ -185,22 +197,28 @@ async def signup(request: Request, body: SignupBody, db: DbSession) -> dict[str,
|
||||
db.rollback()
|
||||
raise
|
||||
|
||||
base_url = str(request.base_url).rstrip("/")
|
||||
try:
|
||||
send_verification_email(body.email, body.username, token, base_url)
|
||||
except Exception as exc:
|
||||
# Email failed — roll back so no unverifiable user row persists.
|
||||
# The user can simply try registering again once SMTP is fixed.
|
||||
db.rollback()
|
||||
logger.warning("Signup email failed for %s: %s", body.email, exc)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail=("Failed to send verification email. Please check that SMTP is correctly configured and try again."),
|
||||
) from exc
|
||||
if smtp_configured and token:
|
||||
base_url = str(request.base_url).rstrip("/")
|
||||
try:
|
||||
send_verification_email(body.email, body.username, token, base_url)
|
||||
except Exception as exc:
|
||||
# Email failed — roll back so no unverifiable user row persists.
|
||||
# The user can simply try registering again once SMTP is fixed.
|
||||
db.rollback()
|
||||
logger.warning("Signup email failed for %s: %s", body.email, exc)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail=(
|
||||
"Failed to send verification email. Please check that SMTP is correctly configured and try again."
|
||||
),
|
||||
) from exc
|
||||
|
||||
db.commit()
|
||||
logger.info("New local user registered: %s", body.email)
|
||||
return {"message": "Verification email sent. Please check your inbox."}
|
||||
|
||||
if smtp_configured:
|
||||
return {"message": "Verification email sent. Please check your inbox.", "email_verification_required": True}
|
||||
return {"message": "Account created successfully. You can now log in.", "email_verification_required": False}
|
||||
|
||||
|
||||
@router.get("/verify-email", include_in_schema=False)
|
||||
|
||||
+73
-24
@@ -127,18 +127,35 @@ async def oauth_login(request: Request):
|
||||
return await oauth.authentik.authorize_redirect(request, redirect_uri)
|
||||
|
||||
|
||||
def _ensure_user_profile(db: Session, user_data: dict) -> None:
|
||||
"""Create a UserProfile row for *user_data* if one does not yet exist.
|
||||
def _ensure_user_profile(db: Session, user_data: dict, is_admin: bool = False) -> None:
|
||||
"""Create or update a UserProfile row for *user_data*.
|
||||
|
||||
Uses the same identifier priority as ``get_current_owner_id`` (sub →
|
||||
preferred_username → email → id) so that the profile's ``user_id`` matches
|
||||
``FileRecord.owner_id`` for every document the user uploads.
|
||||
|
||||
If a profile already exists it is left unchanged; only missing profiles
|
||||
are created so that admin-managed settings (tier, limits, etc.) are
|
||||
preserved across logins.
|
||||
For regular users, an existing profile is left unchanged so that
|
||||
admin-managed settings (tier, limits, etc.) are preserved across logins.
|
||||
|
||||
For admin users (*is_admin=True*) the following rules apply:
|
||||
- If no profile exists: one is created with the highest subscription tier,
|
||||
``is_complimentary=True``, and ``onboarding_completed=True`` so that
|
||||
admins skip the first-time setup wizard.
|
||||
- If a profile already exists: ``is_complimentary`` is set to ``True``
|
||||
and, when the current tier is ``"free"``, the tier is upgraded to the
|
||||
highest available plan. Other admin-managed settings are left intact.
|
||||
|
||||
Args:
|
||||
db: Active database session.
|
||||
user_data: Mapping of user attributes as returned by the OAuth provider
|
||||
or built by :func:`app.utils.local_auth.build_session_user`.
|
||||
is_admin: When ``True``, apply admin-specific defaults on first login
|
||||
and ensure the complimentary flag is always set.
|
||||
"""
|
||||
from app.models import UserProfile
|
||||
from app.utils.subscription import TIER_ORDER
|
||||
|
||||
highest_tier = TIER_ORDER[-1]
|
||||
|
||||
user_id = (
|
||||
user_data.get("sub") or user_data.get("preferred_username") or user_data.get("email") or user_data.get("id")
|
||||
@@ -152,29 +169,58 @@ def _ensure_user_profile(db: Session, user_data: dict) -> None:
|
||||
if existing is None:
|
||||
display_name = user_data.get("name") or user_data.get("preferred_username") or user_data.get("email")
|
||||
email = user_data.get("email")
|
||||
profile = UserProfile(user_id=user_id, display_name=display_name)
|
||||
profile = UserProfile(
|
||||
user_id=user_id,
|
||||
display_name=display_name,
|
||||
subscription_tier=highest_tier if is_admin else "free",
|
||||
is_complimentary=is_admin,
|
||||
onboarding_completed=is_admin,
|
||||
)
|
||||
db.add(profile)
|
||||
db.commit()
|
||||
logger.info("Auto-created UserProfile for user_id=%s", user_id)
|
||||
# Notify admins and fire webhook for new signup
|
||||
try:
|
||||
from app.utils.notification import notify_user_signup
|
||||
from app.utils.webhook import dispatch_webhook_event
|
||||
logger.info(
|
||||
"Auto-created UserProfile for user_id=%s (admin=%s, tier=%s)",
|
||||
user_id,
|
||||
is_admin,
|
||||
highest_tier if is_admin else "free",
|
||||
)
|
||||
# Notify admins and fire webhook for new (non-admin) user signup
|
||||
if not is_admin:
|
||||
try:
|
||||
from app.utils.notification import notify_user_signup
|
||||
from app.utils.webhook import dispatch_webhook_event
|
||||
|
||||
notify_user_signup(user_id, display_name=display_name, email=email)
|
||||
dispatch_webhook_event(
|
||||
"user.signup",
|
||||
{
|
||||
"user_id": user_id,
|
||||
"display_name": display_name,
|
||||
"email": email,
|
||||
},
|
||||
notify_user_signup(user_id, display_name=display_name, email=email)
|
||||
dispatch_webhook_event(
|
||||
"user.signup",
|
||||
{
|
||||
"user_id": user_id,
|
||||
"display_name": display_name,
|
||||
"email": email,
|
||||
},
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("Failed to send signup notification/webhook for user_id=%s", user_id)
|
||||
elif is_admin:
|
||||
# Ensure existing admin profiles always have complimentary flag set.
|
||||
# Also upgrade from free tier to highest if still on default.
|
||||
changed = False
|
||||
if not existing.is_complimentary:
|
||||
existing.is_complimentary = True
|
||||
changed = True
|
||||
if (existing.subscription_tier or "free") == "free":
|
||||
existing.subscription_tier = highest_tier
|
||||
changed = True
|
||||
if changed:
|
||||
db.commit()
|
||||
logger.info(
|
||||
"Updated admin UserProfile for user_id=%s (complimentary=True, tier=%s)",
|
||||
user_id,
|
||||
existing.subscription_tier,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("Failed to send signup notification/webhook for user_id=%s", user_id)
|
||||
except Exception:
|
||||
db.rollback()
|
||||
logger.exception("Failed to auto-create UserProfile for user_id=%s", user_id)
|
||||
logger.exception("Failed to auto-create/update UserProfile for user_id=%s", user_id)
|
||||
|
||||
|
||||
async def oauth_callback(request: Request, db: Session = Depends(get_db)):
|
||||
@@ -210,7 +256,7 @@ async def oauth_callback(request: Request, db: Session = Depends(get_db)):
|
||||
request.session["user"] = user_data
|
||||
|
||||
# Auto-create or update UserProfile so the user appears in admin user management
|
||||
_ensure_user_profile(db, user_data)
|
||||
_ensure_user_profile(db, user_data, is_admin=is_admin)
|
||||
|
||||
# Log the successful authentication
|
||||
logger.info("[SECURITY] OAUTH_LOGIN_SUCCESS user=%s admin=%s", user_data.get("email", "unknown"), is_admin)
|
||||
@@ -268,6 +314,7 @@ async def auth(request: Request, db: Session = Depends(get_db)):
|
||||
user_data = _build_session_user(local_user)
|
||||
request.session["user"] = user_data
|
||||
logger.info("[SECURITY] LOCAL_LOGIN_SUCCESS user=%s", local_user.email)
|
||||
_ensure_user_profile(db, user_data, is_admin=bool(local_user.is_admin))
|
||||
profile = db.query(_UserProfile).filter(_UserProfile.user_id == local_user.email).first()
|
||||
if profile and not profile.onboarding_completed:
|
||||
post_onboarding = request.session.pop("redirect_after_login", "/upload")
|
||||
@@ -278,7 +325,7 @@ async def auth(request: Request, db: Session = Depends(get_db)):
|
||||
|
||||
# --- Admin credentials (always available as a fallback / single-user mode) ---
|
||||
if username == settings.admin_username and password == settings.admin_password:
|
||||
request.session["user"] = {
|
||||
admin_user_data = {
|
||||
"id": "admin",
|
||||
"name": "Administrator",
|
||||
"email": f"{username}@local.docuelevate",
|
||||
@@ -286,7 +333,9 @@ async def auth(request: Request, db: Session = Depends(get_db)):
|
||||
"picture": "/static/images/default-avatar.svg",
|
||||
"is_admin": True,
|
||||
}
|
||||
request.session["user"] = admin_user_data
|
||||
logger.info("[SECURITY] LOCAL_LOGIN_SUCCESS user=%s", username)
|
||||
_ensure_user_profile(db, admin_user_data, is_admin=True)
|
||||
redirect_url = request.session.pop("redirect_after_login", "/upload")
|
||||
return RedirectResponse(url=redirect_url, status_code=302)
|
||||
else:
|
||||
|
||||
@@ -238,6 +238,10 @@ class UserProfile(Base):
|
||||
subscription_period_start = Column(DateTime(timezone=True), nullable=True)
|
||||
allow_overage = Column(Boolean, nullable=False, default=False, server_default="0")
|
||||
|
||||
# When True, the user is on a complimentary (uncharged) plan — they keep all tier
|
||||
# quota benefits but are never billed via Stripe. Automatically set for admin users.
|
||||
is_complimentary = Column(Boolean, nullable=False, default=False, server_default="0")
|
||||
|
||||
# Onboarding tracking (added in migration 017)
|
||||
onboarding_completed = Column(Boolean, nullable=False, default=False, server_default="0")
|
||||
onboarding_completed_at = Column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
+18
-14
@@ -1,11 +1,12 @@
|
||||
"""
|
||||
Subscription tier definitions and enforcement utilities for DocuElevate SaaS.
|
||||
|
||||
All plans are priced per user per month (or per year with ~20 % discount).
|
||||
Four tiers (prices ex-VAT; German customers +19 % MwSt):
|
||||
- free $0/mo — 50 lifetime docs, 150 lifetime OCR pages, 1 dest
|
||||
- starter $2.99/mo — 50/mo, 300 OCR pp/mo, 2 dests, 1 mailbox
|
||||
- professional $5.99/mo — 150/mo, 750 OCR pp/mo, 5 dests, 3 mailboxes
|
||||
- business $7.99/mo — 300/mo, 1500 OCR pp/mo, 10 dests, unlimited mailboxes
|
||||
- power $7.99/mo — 300/mo, 1500 OCR pp/mo, 10 dests, unlimited mailboxes
|
||||
|
||||
Limits use 0 to represent "unlimited".
|
||||
All paid tiers include a 30-day free trial (trial_days field).
|
||||
@@ -14,14 +15,14 @@ All paid tiers include a 30-day free trial (trial_days field).
|
||||
Infrastructure: CX32 (app+Redis €7.59) + CX22 (worker €3.79) + BX21 (storage €7.22) ≈ $24/mo
|
||||
At 100 users infra share ≈ $0.24/user/mo.
|
||||
|
||||
Starter : OCR $0.45 + AI $0.012 + infra $0.24 + Stripe $0.34 = $1.04 → 65 % gross margin
|
||||
Starter : OCR $0.45 + AI $0.012 + infra $0.24 + Stripe $0.34 = $1.04 → 65 % gross margin
|
||||
Professional: OCR $1.13 + AI $0.035 + infra $0.24 + Stripe $0.42 = $1.82 → 70 % gross margin
|
||||
Business : OCR $2.25 + AI $0.069 + infra $0.24 + Stripe $0.48 = $3.04 → 62 % gross margin
|
||||
Power : OCR $2.25 + AI $0.069 + infra $0.24 + Stripe $0.48 = $3.04 → 62 % gross margin
|
||||
|
||||
After ~30 % German corporate tax: Starter 45 %, Professional 49 %, Business 43 %.
|
||||
After ~30 % German corporate tax: Starter 45 %, Professional 49 %, Power 43 %.
|
||||
At average usage (~40 % of quota) margins improve to 55-65 % after tax.
|
||||
|
||||
⚠ If GPT-4o (not mini) is configured, Business AI cost at max rises to ~$1.92/user,
|
||||
⚠ If GPT-4o (not mini) is configured, Power AI cost at max rises to ~$1.92/user,
|
||||
reducing after-tax margin to ~33 %. Recommend GPT-4o mini as default in production.
|
||||
"""
|
||||
|
||||
@@ -46,7 +47,7 @@ TIER_DEFAULTS: dict[str, dict[str, Any]] = {
|
||||
"free": {
|
||||
"id": "free",
|
||||
"name": "Free",
|
||||
"tagline": "Explore DocuElevate at no cost",
|
||||
"tagline": "Try DocuElevate free — no credit card needed",
|
||||
"price_monthly": 0,
|
||||
"price_yearly": 0,
|
||||
"trial_days": 0,
|
||||
@@ -75,7 +76,8 @@ TIER_DEFAULTS: dict[str, dict[str, Any]] = {
|
||||
"starter": {
|
||||
"id": "starter",
|
||||
"name": "Starter",
|
||||
"tagline": "Perfect for individuals getting started",
|
||||
# Use case: freelancer sending ~50 invoices, contracts, or scanned receipts a month
|
||||
"tagline": "Perfect for freelancers and side-project owners",
|
||||
"price_monthly": 2.99,
|
||||
"price_yearly": 28.99, # ≈ 80 % of monthly × 12 — save ~19 % (≈ 2½ months free)
|
||||
"trial_days": 30,
|
||||
@@ -89,7 +91,7 @@ TIER_DEFAULTS: dict[str, dict[str, Any]] = {
|
||||
"max_mailboxes": 1,
|
||||
"api_access": True,
|
||||
"features": [
|
||||
"50 documents / month",
|
||||
"50 documents / month — invoices, contracts, receipts",
|
||||
"2 storage destinations",
|
||||
"300 OCR pages / month",
|
||||
"25 MB max file size",
|
||||
@@ -104,7 +106,8 @@ TIER_DEFAULTS: dict[str, dict[str, Any]] = {
|
||||
"professional": {
|
||||
"id": "professional",
|
||||
"name": "Professional",
|
||||
"tagline": "For growing teams that need more power",
|
||||
# Use case: consultant or knowledge worker handling ~150 docs/month across multiple platforms
|
||||
"tagline": "For knowledge workers managing documents daily",
|
||||
"price_monthly": 5.99,
|
||||
"price_yearly": 57.99, # ≈ 80 % of monthly × 12 — save ~19 %
|
||||
"trial_days": 30,
|
||||
@@ -118,7 +121,7 @@ TIER_DEFAULTS: dict[str, dict[str, Any]] = {
|
||||
"max_mailboxes": 3,
|
||||
"api_access": True,
|
||||
"features": [
|
||||
"150 documents / month",
|
||||
"150 documents / month — reports, contracts, invoices",
|
||||
"5 storage destinations",
|
||||
"750 OCR pages / month",
|
||||
"100 MB max file size",
|
||||
@@ -133,8 +136,9 @@ TIER_DEFAULTS: dict[str, dict[str, Any]] = {
|
||||
},
|
||||
"business": {
|
||||
"id": "business",
|
||||
"name": "Business",
|
||||
"tagline": "High-volume processing for organisations",
|
||||
"name": "Power",
|
||||
# Use case: power user — real estate agent, bookkeeper, or researcher processing ~10 docs/day
|
||||
"tagline": "For power users with high-volume document workflows",
|
||||
"price_monthly": 7.99,
|
||||
"price_yearly": 76.99, # ≈ 80 % of monthly × 12 — save ~20 %
|
||||
"trial_days": 30,
|
||||
@@ -148,7 +152,7 @@ TIER_DEFAULTS: dict[str, dict[str, Any]] = {
|
||||
"max_mailboxes": 0, # unlimited mailboxes
|
||||
"api_access": True,
|
||||
"features": [
|
||||
"300 documents / month",
|
||||
"300 documents / month — ~10 documents per day",
|
||||
"10 storage destinations",
|
||||
"1,500 OCR pages / month",
|
||||
"Unlimited file size",
|
||||
@@ -156,7 +160,7 @@ TIER_DEFAULTS: dict[str, dict[str, Any]] = {
|
||||
"Unlimited email ingestion mailboxes",
|
||||
"All ingestion methods",
|
||||
"Webhooks & full API access",
|
||||
"Dedicated support",
|
||||
"Priority support",
|
||||
],
|
||||
"cta": "Start free trial",
|
||||
"badge": "Best Value",
|
||||
|
||||
@@ -32,6 +32,10 @@ def _inject_global_context(ctx: dict) -> None:
|
||||
ctx.setdefault("ui_default_color_scheme", getattr(settings, "ui_default_color_scheme", "system"))
|
||||
ctx.setdefault("multi_user_enabled", getattr(settings, "multi_user_enabled", False))
|
||||
ctx.setdefault("auth_enabled", getattr(settings, "auth_enabled", True))
|
||||
ctx.setdefault(
|
||||
"allow_signup",
|
||||
getattr(settings, "multi_user_enabled", False) and getattr(settings, "allow_local_signup", False),
|
||||
)
|
||||
|
||||
req = ctx.get("request")
|
||||
if req is not None:
|
||||
|
||||
@@ -123,6 +123,7 @@ async def serve_index(request: Request, db: Session = Depends(get_db)):
|
||||
"user_tier": user_tier,
|
||||
"multi_user_enabled": settings.multi_user_enabled,
|
||||
"is_admin": is_admin,
|
||||
"allow_signup": settings.multi_user_enabled and settings.allow_local_signup,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user