+
+ {% set faqs = [
+ ("What counts as a 'processed file'?",
+ "Every document you upload and run through the DocuElevate pipeline counts as one processed file — including OCR, AI metadata extraction, and storage delivery."),
+ ("What happens when I reach the Free tier lifetime limit?",
+ "Once your 25-file lifetime quota is reached you will see a friendly upgrade prompt on the upload page and any further upload attempts will return a payment-required error until you upgrade to a paid plan."),
+ ("Can I change my plan at any time?",
+ "Yes. Upgrades take effect immediately. Downgrades take effect at the start of the next billing cycle. Unused quota does not roll over between billing periods."),
+ ("Is there an annual discount?",
+ "Yes — paying annually saves approximately 17% compared to monthly billing. The savings are shown in the annual pricing above."),
+ ("Do you offer a trial for paid plans?",
+ "The Free tier lets you try DocuElevate with up to 25 files at no cost and with no credit card required. Paid trials can be arranged — contact sales."),
+ ("What is a 'storage destination'?",
+ "A storage destination is any cloud or self-hosted storage you configure as an output — Dropbox, Google Drive, OneDrive, Nextcloud, S3, SFTP, FTP, WebDAV, or Paperless-ngx each count as one destination."),
+ ] %}
+
+ {% for q, a in faqs %}
+
+
+
+ {{ a }}
+
+
+ {% endfor %}
+
+
+
+
+
+
+
Ready to get started?
+
Start with the free tier — no credit card required.
+ Subscription tiers apply when multi-user mode is active. Your instance currently runs in
+ single-user mode with no processing limits.
+ An admin can enable multi-user mode via Settings.
+
+
+
+
+ {% else %}
+
+
+
+
+
+
+
+
+
+
Current Plan
+
{{ tier.name }}
+
{{ tier.tagline }}
+ {% if tier.price_monthly > 0 %}
+
${{ tier.price_monthly }}/month
+ {% else %}
+
Free
+ {% endif %}
+
+
+
+ {% if usage %}
+
+
Usage
+
+
+
+
+
{{ usage.lifetime }}
+
Total files (lifetime)
+ {% if tier.lifetime_file_limit > 0 %}
+
+ {% set lifetime_pct = ((usage.lifetime / tier.lifetime_file_limit) * 100) | int %}
+
{% for tier in tiers %}
diff --git a/migrations/versions/015_add_subscription_plans.py b/migrations/versions/015_add_subscription_plans.py
new file mode 100644
index 00000000..5f8ce557
--- /dev/null
+++ b/migrations/versions/015_add_subscription_plans.py
@@ -0,0 +1,70 @@
+"""Add subscription_plans table
+
+Revision ID: 015_add_subscription_plans
+Revises: 014_add_subscription_tiers
+Create Date: 2026-03-07
+
+"""
+
+from typing import Union
+
+import sqlalchemy as sa
+from alembic import op
+
+# revision identifiers, used by Alembic.
+revision: str = "015_add_subscription_plans"
+down_revision: Union[str, None] = "014_add_subscription_tiers"
+depends_on: Union[str, None] = None
+
+
+def upgrade() -> None:
+ """Create subscription_plans table for admin-configurable plan definitions."""
+ op.create_table(
+ "subscription_plans",
+ sa.Column("id", sa.Integer(), primary_key=True, index=True, nullable=False),
+ sa.Column("plan_id", sa.String(50), unique=True, nullable=False, index=True),
+ sa.Column("name", sa.String(100), nullable=False),
+ sa.Column("tagline", sa.String(255), nullable=True),
+ # Pricing
+ sa.Column("price_monthly", sa.Float(), nullable=False, server_default="0.0"),
+ sa.Column("price_yearly", sa.Float(), nullable=False, server_default="0.0"),
+ sa.Column("trial_days", sa.Integer(), nullable=False, server_default="0"),
+ # Volume limits
+ sa.Column("lifetime_file_limit", sa.Integer(), nullable=False, server_default="0"),
+ sa.Column("daily_upload_limit", sa.Integer(), nullable=False, server_default="0"),
+ sa.Column("monthly_upload_limit", sa.Integer(), nullable=False, server_default="0"),
+ sa.Column("max_storage_destinations", sa.Integer(), nullable=False, server_default="0"),
+ sa.Column("max_ocr_pages_monthly", sa.Integer(), nullable=False, server_default="0"),
+ sa.Column("max_file_size_mb", sa.Integer(), nullable=False, server_default="0"),
+ sa.Column("max_mailboxes", sa.Integer(), nullable=False, server_default="0"),
+ # Overage
+ sa.Column("overage_percent", sa.Integer(), nullable=False, server_default="20"),
+ sa.Column("allow_overage_billing", sa.Boolean(), nullable=False, server_default="0"),
+ sa.Column("overage_price_per_doc", sa.Float(), nullable=True),
+ sa.Column("overage_price_per_ocr_page", sa.Float(), nullable=True),
+ # Display / marketing
+ sa.Column("is_active", sa.Boolean(), nullable=False, server_default="1"),
+ sa.Column("is_highlighted", sa.Boolean(), nullable=False, server_default="0"),
+ sa.Column("badge_text", sa.String(50), nullable=True),
+ sa.Column("cta_text", sa.String(100), nullable=False, server_default="Get started"),
+ sa.Column("sort_order", sa.Integer(), nullable=False, server_default="0"),
+ sa.Column("features", sa.Text(), nullable=True),
+ sa.Column("api_access", sa.Boolean(), nullable=False, server_default="0"),
+ sa.Column(
+ "created_at",
+ sa.DateTime(timezone=True),
+ server_default=sa.text("CURRENT_TIMESTAMP"),
+ nullable=False,
+ ),
+ sa.Column(
+ "updated_at",
+ sa.DateTime(timezone=True),
+ server_default=sa.text("CURRENT_TIMESTAMP"),
+ nullable=False,
+ ),
+ )
+
+
+def downgrade() -> None:
+ """Drop subscription_plans table."""
+ op.drop_table("subscription_plans")
diff --git a/migrations/versions/016_add_userprofile_billing.py b/migrations/versions/016_add_userprofile_billing.py
new file mode 100644
index 00000000..60a8c7de
--- /dev/null
+++ b/migrations/versions/016_add_userprofile_billing.py
@@ -0,0 +1,45 @@
+"""Add billing cycle and overage columns to user_profiles
+
+Revision ID: 016_add_userprofile_billing
+Revises: 015_add_subscription_plans
+Create Date: 2026-03-07
+
+"""
+
+from typing import Union
+
+import sqlalchemy as sa
+from alembic import op
+
+# revision identifiers, used by Alembic.
+revision: str = "016_add_userprofile_billing"
+down_revision: Union[str, None] = "015_add_subscription_plans"
+depends_on: Union[str, None] = None
+
+
+def upgrade() -> None:
+ """Add subscription_billing_cycle, subscription_period_start, allow_overage to user_profiles."""
+ op.add_column(
+ "user_profiles",
+ sa.Column(
+ "subscription_billing_cycle",
+ sa.String(10),
+ nullable=False,
+ server_default="monthly",
+ ),
+ )
+ op.add_column(
+ "user_profiles",
+ sa.Column("subscription_period_start", sa.DateTime(timezone=True), nullable=True),
+ )
+ op.add_column(
+ "user_profiles",
+ sa.Column("allow_overage", sa.Boolean(), nullable=False, server_default="0"),
+ )
+
+
+def downgrade() -> None:
+ """Remove billing columns from user_profiles."""
+ op.drop_column("user_profiles", "allow_overage")
+ op.drop_column("user_profiles", "subscription_period_start")
+ op.drop_column("user_profiles", "subscription_billing_cycle")
diff --git a/tests/test_subscription.py b/tests/test_subscription.py
index d0e2fce5..2f3a66c1 100644
--- a/tests/test_subscription.py
+++ b/tests/test_subscription.py
@@ -1,27 +1,24 @@
"""Unit tests for the subscription tier utility module."""
-import pytest
-from sqlalchemy import create_engine
-from sqlalchemy.orm import sessionmaker
+from datetime import datetime, timezone
from unittest.mock import MagicMock, patch
-from datetime import datetime, timezone, date
+
+import pytest
from app.utils.subscription import (
- TIERS,
- TIER_ORDER,
DEFAULT_TIER,
- get_tier,
+ TIER_DEFAULTS,
+ TIER_ORDER,
+ TIERS,
+ QuotaExceeded,
+ _months_elapsed,
+ check_upload_allowed,
get_all_tiers,
+ get_tier,
get_user_tier_id,
get_user_usage,
- check_upload_allowed,
- QuotaExceeded,
- get_lifetime_file_count,
- get_today_file_count,
- get_month_file_count,
)
-
# ---------------------------------------------------------------------------
# Basic catalogue tests
# ---------------------------------------------------------------------------
@@ -69,12 +66,21 @@ def test_get_all_tiers_returns_four():
@pytest.mark.unit
def test_all_tiers_have_required_fields():
required = [
- "id", "name", "tagline", "price_monthly", "price_yearly",
+ "id",
+ "name",
+ "tagline",
+ "price_monthly",
+ "price_yearly",
"trial_days",
- "lifetime_file_limit", "daily_upload_limit", "monthly_upload_limit",
- "max_storage_destinations", "max_ocr_pages_monthly", "max_file_size_mb",
+ "lifetime_file_limit",
+ "daily_upload_limit",
+ "monthly_upload_limit",
+ "max_storage_destinations",
+ "max_ocr_pages_monthly",
+ "max_file_size_mb",
"max_mailboxes",
- "features", "cta",
+ "features",
+ "cta",
]
for tid, tier in TIERS.items():
for field in required:
@@ -103,18 +109,21 @@ def test_free_tier_has_no_mailboxes():
def test_business_tier_has_highest_limits():
"""Business tier must have the highest limits of all paid tiers."""
t = TIERS["business"]
- # lifetime, daily, monthly: no hard cap (0 = unlimited) for lifetime; daily/monthly capped
+ # lifetime: no hard cap (0 = unlimited)
assert t["lifetime_file_limit"] == 0
- assert t["daily_upload_limit"] == 30
+ # no daily cap (0 = unlimited)
+ assert t["daily_upload_limit"] == 0
assert t["monthly_upload_limit"] == 300
assert t["max_ocr_pages_monthly"] == 1500
- # unlimited mailboxes
+ # unlimited mailboxes (0 = unlimited)
assert t["max_mailboxes"] == 0
+ # unlimited file size (0 = unlimited)
+ assert t["max_file_size_mb"] == 0
@pytest.mark.unit
def test_mailbox_limits_increase_by_tier():
- """Mailbox limits must increase across tiers: free=0, starter=1, professional=3, business=0(∞)."""
+ """Mailbox limits must increase across tiers: free=0, starter=1, professional=3, business=0(inf)."""
assert TIERS["free"]["max_mailboxes"] == 0
assert TIERS["starter"]["max_mailboxes"] == 1
assert TIERS["professional"]["max_mailboxes"] == 3
@@ -197,10 +206,16 @@ def test_check_upload_skipped_without_tier():
@pytest.mark.unit
def test_check_upload_raises_when_lifetime_exceeded():
- """Free tier: should raise QuotaExceeded when lifetime limit (50) is hit."""
+ """Free tier: raise QuotaExceeded at lifetime limit (50) with 0% buffer (exact enforcement)."""
db = MagicMock()
+ # Return None for both SubscriptionPlan lookup and UserProfile lookup
+ db.query.return_value.filter.return_value.first.return_value = None
- with patch("app.utils.subscription.get_lifetime_file_count", return_value=50):
+ with (
+ patch("app.utils.subscription.settings") as mock_settings,
+ patch("app.utils.subscription.get_lifetime_file_count", return_value=50),
+ ):
+ mock_settings.subscription_overage_percent = 0
with pytest.raises(QuotaExceeded) as exc_info:
check_upload_allowed(db, "user@example.com", "free")
@@ -212,31 +227,34 @@ def test_check_upload_raises_when_lifetime_exceeded():
@pytest.mark.unit
def test_check_upload_passes_below_lifetime_limit():
db = MagicMock()
- with patch("app.utils.subscription.get_lifetime_file_count", return_value=10):
+ db.query.return_value.filter.return_value.first.return_value = None
+
+ with (
+ patch("app.utils.subscription.settings") as mock_settings,
+ patch("app.utils.subscription.get_lifetime_file_count", return_value=10),
+ ):
+ mock_settings.subscription_overage_percent = 0
check_upload_allowed(db, "user@example.com", "free") # must not raise
-@pytest.mark.unit
-def test_check_upload_raises_when_daily_exceeded():
- """Starter tier: should raise QuotaExceeded when daily limit (5) is hit."""
- db = MagicMock()
-
- with patch("app.utils.subscription.get_lifetime_file_count", return_value=0), \
- patch("app.utils.subscription.get_today_file_count", return_value=5):
- with pytest.raises(QuotaExceeded) as exc_info:
- check_upload_allowed(db, "user@example.com", "starter")
-
- assert exc_info.value.limit_type == "daily"
-
-
@pytest.mark.unit
def test_check_upload_raises_when_monthly_exceeded():
- """Starter tier: should raise QuotaExceeded when monthly limit (50) is hit."""
+ """Starter tier: raise QuotaExceeded when monthly limit (50) is hit (0% buffer)."""
db = MagicMock()
- with patch("app.utils.subscription.get_lifetime_file_count", return_value=0), \
- patch("app.utils.subscription.get_today_file_count", return_value=0), \
- patch("app.utils.subscription.get_month_file_count", return_value=50):
+ # UserProfile mock: no overage, monthly billing, no period_start
+ profile_mock = MagicMock()
+ profile_mock.allow_overage = False
+ profile_mock.subscription_billing_cycle = "monthly"
+ profile_mock.subscription_period_start = None
+ db.query.return_value.filter.return_value.first.side_effect = [None, profile_mock]
+
+ with (
+ patch("app.utils.subscription.settings") as mock_settings,
+ patch("app.utils.subscription.get_lifetime_file_count", return_value=0),
+ patch("app.utils.subscription.get_month_file_count", return_value=50),
+ ):
+ mock_settings.subscription_overage_percent = 0
with pytest.raises(QuotaExceeded) as exc_info:
check_upload_allowed(db, "user@example.com", "starter")
@@ -245,27 +263,154 @@ def test_check_upload_raises_when_monthly_exceeded():
@pytest.mark.unit
def test_check_upload_business_tier_within_limits():
- """Business tier: upload is allowed as long as counts are below the capped limits."""
+ """Business tier: upload is allowed when count is below the monthly limit."""
db = MagicMock()
- # Use counts well below Business limits (30/day, 300/mo)
- with patch("app.utils.subscription.get_lifetime_file_count", return_value=0), \
- patch("app.utils.subscription.get_today_file_count", return_value=10), \
- patch("app.utils.subscription.get_month_file_count", return_value=100):
+ profile_mock = MagicMock()
+ profile_mock.allow_overage = False
+ profile_mock.subscription_billing_cycle = "monthly"
+ profile_mock.subscription_period_start = None
+ db.query.return_value.filter.return_value.first.side_effect = [None, profile_mock]
+
+ with (
+ patch("app.utils.subscription.settings") as mock_settings,
+ patch("app.utils.subscription.get_lifetime_file_count", return_value=0),
+ patch("app.utils.subscription.get_month_file_count", return_value=100),
+ ):
+ mock_settings.subscription_overage_percent = 0
check_upload_allowed(db, "user@example.com", "business") # must not raise
+# ---------------------------------------------------------------------------
+# Overage buffer tests
+# ---------------------------------------------------------------------------
+
+
@pytest.mark.unit
-def test_check_upload_business_tier_raises_when_daily_exceeded():
- """Business tier: should raise QuotaExceeded when daily limit (30) is hit."""
+def test_overage_percent_allows_buffer():
+ """Starter monthly=50, 20% buffer -> enforce at 60. count=55 should pass, count=61 should raise."""
+ profile_mock = MagicMock()
+ profile_mock.allow_overage = False
+ profile_mock.subscription_billing_cycle = "monthly"
+ profile_mock.subscription_period_start = None
+
db = MagicMock()
+ db.query.return_value.filter.return_value.first.side_effect = [None, profile_mock]
- with patch("app.utils.subscription.get_lifetime_file_count", return_value=0), \
- patch("app.utils.subscription.get_today_file_count", return_value=30):
+ with (
+ patch("app.utils.subscription.settings") as mock_settings,
+ patch("app.utils.subscription.get_lifetime_file_count", return_value=0),
+ patch("app.utils.subscription.get_month_file_count", return_value=55),
+ ):
+ mock_settings.subscription_overage_percent = 20
+ # count=55 < 60 (50*1.20) -> should NOT raise
+ check_upload_allowed(db, "user@example.com", "starter")
+
+ # Reset mock for second call
+ db2 = MagicMock()
+ db2.query.return_value.filter.return_value.first.side_effect = [None, profile_mock]
+
+ with (
+ patch("app.utils.subscription.settings") as mock_settings,
+ patch("app.utils.subscription.get_lifetime_file_count", return_value=0),
+ patch("app.utils.subscription.get_month_file_count", return_value=61),
+ ):
+ mock_settings.subscription_overage_percent = 20
+ # count=61 >= 60 -> should raise
with pytest.raises(QuotaExceeded) as exc_info:
- check_upload_allowed(db, "user@example.com", "business")
+ check_upload_allowed(db2, "user@example.com", "starter")
+ assert exc_info.value.limit_type == "monthly"
+ assert exc_info.value.limit_value == 50
- assert exc_info.value.limit_type == "daily"
- assert exc_info.value.limit_value == 30
+
+@pytest.mark.unit
+def test_allow_overage_flag_bypasses_monthly_limit():
+ """When allow_overage=True on UserProfile, monthly cap is never enforced."""
+ db = MagicMock()
+ profile_mock = MagicMock()
+ profile_mock.allow_overage = True
+ profile_mock.subscription_billing_cycle = "monthly"
+ profile_mock.subscription_period_start = None
+ db.query.return_value.filter.return_value.first.side_effect = [None, profile_mock]
+
+ with (
+ patch("app.utils.subscription.settings") as mock_settings,
+ patch("app.utils.subscription.get_lifetime_file_count", return_value=0),
+ patch("app.utils.subscription.get_month_file_count", return_value=999999),
+ ):
+ mock_settings.subscription_overage_percent = 0
+ # Should NOT raise even with enormous count
+ check_upload_allowed(db, "user@example.com", "starter")
+
+
+@pytest.mark.unit
+def test_yearly_carryover_allows_accumulated_budget():
+ """Yearly billing carry-over: period_start 2 months ago, monthly=50 (0% buffer).
+ Budget = 50 * months_elapsed. used=80 should pass; used at budget+1 should raise.
+ """
+ db = MagicMock()
+ profile_mock = MagicMock()
+ profile_mock.allow_overage = False
+ profile_mock.subscription_billing_cycle = "yearly"
+ now = datetime.now(timezone.utc)
+ # period_start is 2 months before current month
+ if now.month > 2:
+ period_start = now.replace(month=now.month - 2, day=1)
+ else:
+ period_start = now.replace(year=now.year - 1, month=now.month + 10, day=1)
+ profile_mock.subscription_period_start = period_start
+ db.query.return_value.filter.return_value.first.side_effect = [None, profile_mock]
+
+ # months_elapsed with period 2 months ago = 3 (prev-prev, prev, current)
+ # budget = 50 * 3 = 150 with 0% buffer
+ with (
+ patch("app.utils.subscription.settings") as mock_settings,
+ patch("app.utils.subscription.get_lifetime_file_count", return_value=0),
+ patch("app.utils.subscription.get_year_file_count", return_value=80),
+ ):
+ mock_settings.subscription_overage_percent = 0
+ # 80 < 150 -> should NOT raise
+ check_upload_allowed(db, "user@example.com", "starter")
+
+ # Reset mock for second call
+ db2 = MagicMock()
+ db2.query.return_value.filter.return_value.first.side_effect = [None, profile_mock]
+
+ with (
+ patch("app.utils.subscription.settings") as mock_settings,
+ patch("app.utils.subscription.get_lifetime_file_count", return_value=0),
+ patch("app.utils.subscription.get_year_file_count", return_value=151),
+ ):
+ mock_settings.subscription_overage_percent = 0
+ # 151 >= 150 -> should raise
+ with pytest.raises(QuotaExceeded) as exc_info:
+ check_upload_allowed(db2, "user@example.com", "starter")
+ assert exc_info.value.limit_type == "monthly"
+
+
+# ---------------------------------------------------------------------------
+# _months_elapsed helper
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.unit
+def test_months_elapsed_same_month():
+ now = datetime(2025, 6, 15, tzinfo=timezone.utc)
+ start = datetime(2025, 6, 1, tzinfo=timezone.utc)
+ assert _months_elapsed(start, now) == 1
+
+
+@pytest.mark.unit
+def test_months_elapsed_two_months():
+ now = datetime(2025, 8, 1, tzinfo=timezone.utc)
+ start = datetime(2025, 6, 1, tzinfo=timezone.utc)
+ assert _months_elapsed(start, now) == 3 # June, July, August = 3
+
+
+@pytest.mark.unit
+def test_months_elapsed_clamped_to_12():
+ now = datetime(2026, 6, 1, tzinfo=timezone.utc)
+ start = datetime(2024, 1, 1, tzinfo=timezone.utc)
+ assert _months_elapsed(start, now) == 12
# ---------------------------------------------------------------------------
@@ -276,14 +421,52 @@ def test_check_upload_business_tier_raises_when_daily_exceeded():
@pytest.mark.unit
def test_get_user_usage_returns_dict_with_correct_keys():
db = MagicMock()
- with patch("app.utils.subscription.get_lifetime_file_count", return_value=10), \
- patch("app.utils.subscription.get_today_file_count", return_value=2), \
- patch("app.utils.subscription.get_month_file_count", return_value=8):
+ # No profile -> monthly billing, no period_start
+ db.query.return_value.filter.return_value.first.return_value = None
+
+ with (
+ patch("app.utils.subscription.get_lifetime_file_count", return_value=10),
+ patch("app.utils.subscription.get_today_file_count", return_value=2),
+ patch("app.utils.subscription.get_month_file_count", return_value=8),
+ ):
result = get_user_usage(db, "user@example.com")
assert result == {"lifetime": 10, "today": 2, "month": 8}
+@pytest.mark.unit
+def test_get_user_usage_includes_year_to_date_for_yearly():
+ """Yearly subscriber gets year_to_date key in usage dict."""
+ db = MagicMock()
+ profile_mock = MagicMock()
+ profile_mock.subscription_billing_cycle = "yearly"
+ period_start = datetime(2025, 1, 1, tzinfo=timezone.utc)
+ profile_mock.subscription_period_start = period_start
+ db.query.return_value.filter.return_value.first.return_value = profile_mock
+
+ with (
+ patch("app.utils.subscription.get_lifetime_file_count", return_value=10),
+ patch("app.utils.subscription.get_today_file_count", return_value=2),
+ patch("app.utils.subscription.get_month_file_count", return_value=8),
+ patch("app.utils.subscription.get_year_file_count", return_value=40),
+ ):
+ result = get_user_usage(db, "user@example.com")
+
+ assert "year_to_date" in result
+ assert result["year_to_date"] == 40
+
+
+# ---------------------------------------------------------------------------
+# TIERS / TIER_DEFAULTS alias
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.unit
+def test_tiers_is_alias_for_tier_defaults():
+ """TIERS must be the same object as TIER_DEFAULTS (backward compat alias)."""
+ assert TIERS is TIER_DEFAULTS
+
+
# ---------------------------------------------------------------------------
# API: /api/subscriptions/tiers (integration-style, mocked app)
# ---------------------------------------------------------------------------
From ab532b55dcdf5bface2ac617db034c3d96b6a3ab Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Fri, 6 Mar 2026 21:09:44 +0000
Subject: [PATCH 7/9] fix: resolve mypy and djlint CI failures
- app/utils/subscription.py: add Any type annotation to _scalar_count()
query parameter (mypy no-untyped-def error at line 316)
- frontend/templates/admin_plans.html: remove empty at line 344
(djlint H020 empty tag pair error)
Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
---
app/utils/subscription.py | 2 +-
frontend/templates/admin_plans.html | 1 -
2 files changed, 1 insertion(+), 2 deletions(-)
diff --git a/app/utils/subscription.py b/app/utils/subscription.py
index d7dd92c0..6f7ea841 100644
--- a/app/utils/subscription.py
+++ b/app/utils/subscription.py
@@ -313,7 +313,7 @@ def _today_utc() -> date:
return datetime.now(timezone.utc).date()
-def _scalar_count(query) -> int:
+def _scalar_count(query: Any) -> int:
"""Execute a count query and return an int, defaulting to 0 for NULL."""
return query.scalar() or 0
diff --git a/frontend/templates/admin_plans.html b/frontend/templates/admin_plans.html
index b0f1b098..e4e4cc6b 100644
--- a/frontend/templates/admin_plans.html
+++ b/frontend/templates/admin_plans.html
@@ -341,7 +341,6 @@
Coming soon
-
Date: Fri, 6 Mar 2026 21:22:46 +0000
Subject: [PATCH 8/9] fix(api): move quota check before file write in ui-upload
endpoint
Subscription quota is now checked before the file is written to disk,
so users who have exceeded their quota do not waste bandwidth or disk
I/O. The post-write cleanup path for quota rejections is no longer
needed and has been removed.
Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
---
app/api/files.py | 37 +++++++++++++++++--------------------
1 file changed, 17 insertions(+), 20 deletions(-)
diff --git a/app/api/files.py b/app/api/files.py
index 35691a7b..87586664 100644
--- a/app/api/files.py
+++ b/app/api/files.py
@@ -1256,6 +1256,23 @@ async def ui_upload(request: Request, db: DbSession, file: UploadFile = File(...
# Store both the safe original name and the unique name
target_path = os.path.join(workdir, target_filename)
+ # Determine the owner_id for multi-user document isolation
+ upload_owner_id = get_current_owner_id(request) if settings.multi_user_enabled else None
+
+ # Enforce subscription tier upload quotas (multi-user mode only) BEFORE writing the file
+ # so that users who have exceeded their quota do not waste bandwidth or disk I/O.
+ if settings.multi_user_enabled and upload_owner_id:
+ from app.utils.subscription import QuotaExceeded, check_upload_allowed, get_user_tier_id
+
+ tier_id = get_user_tier_id(db, upload_owner_id)
+ try:
+ check_upload_allowed(db, upload_owner_id, tier_id)
+ except QuotaExceeded as qe:
+ raise HTTPException(
+ status_code=status.HTTP_402_PAYMENT_REQUIRED,
+ detail=str(qe),
+ )
+
# Read file in chunks to avoid loading the entire body into memory at once,
# enforcing the size limit during the read so memory usage stays bounded.
try:
@@ -1292,26 +1309,6 @@ async def ui_upload(request: Request, db: DbSession, file: UploadFile = File(...
mime_type, _ = mimetypes.guess_type(target_path)
file_ext = os.path.splitext(target_path)[1].lower()
- # Determine the owner_id for multi-user document isolation
- upload_owner_id = get_current_owner_id(request) if settings.multi_user_enabled else None
-
- # Enforce subscription tier upload quotas (multi-user mode only)
- if settings.multi_user_enabled and upload_owner_id:
- from app.utils.subscription import QuotaExceeded, check_upload_allowed, get_user_tier_id
-
- tier_id = get_user_tier_id(db, upload_owner_id)
- try:
- check_upload_allowed(db, upload_owner_id, tier_id)
- except QuotaExceeded as qe:
- # Clean up the temporarily written file before returning the error
- # to avoid consuming disk space for a rejected upload.
- if os.path.exists(target_path):
- os.remove(target_path)
- raise HTTPException(
- status_code=status.HTTP_402_PAYMENT_REQUIRED,
- detail=str(qe),
- )
-
# Check if it's a PDF by extension or MIME type
is_pdf = file_ext == ".pdf" or mime_type == "application/pdf"
From 9853a27d82a5c68ca01b9350365589837b0f8911 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Fri, 6 Mar 2026 21:29:44 +0000
Subject: [PATCH 9/9] fix: add subscription_overage_percent to SETTING_METADATA
and docs
Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
---
.env.demo | 6 ++++++
app/utils/settings_service.py | 13 +++++++++++++
docs/ConfigurationGuide.md | 10 ++++++++++
3 files changed, 29 insertions(+)
diff --git a/.env.demo b/.env.demo
index a5bb7f16..7fe4cd67 100644
--- a/.env.demo
+++ b/.env.demo
@@ -141,6 +141,12 @@ UNOWNED_DOCS_VISIBLE_TO_ALL=true
# Leave empty/unset to keep them unowned until claimed.
# DEFAULT_OWNER_ID=
+# **Subscription / Quota Settings**
+# Soft-limit overage buffer in percent (0–200). Announced quota is multiplied by (1 + percent/100)
+# for actual enforcement. E.g. 20 means a 150-doc/month plan enforces at 180. 0 = enforce exactly.
+# Per-plan overage_percent set in the Plan Designer overrides this global default.
+# SUBSCRIPTION_OVERAGE_PERCENT=20
+
# **OpenID Connect/Authentik Settings**
AUTHENTIK_CLIENT_ID=
AUTHENTIK_CLIENT_SECRET=
diff --git a/app/utils/settings_service.py b/app/utils/settings_service.py
index 9c79e2d8..c11e64d6 100644
--- a/app/utils/settings_service.py
+++ b/app/utils/settings_service.py
@@ -1771,6 +1771,19 @@ SETTING_METADATA = {
"required": False,
"restart_required": True,
},
+ "subscription_overage_percent": {
+ "category": "Subscriptions",
+ "description": (
+ "Soft-limit overage buffer in percent (0–200). The announced monthly quota is increased by this "
+ "percentage for actual enforcement. For example, 20 means a 150-doc/month plan enforces at 180 docs "
+ "(150 × 1.20). Set to 0 to enforce exactly at the announced limit. Per-plan overage_percent configured "
+ "in the Plan Designer overrides this global default. Default: 20."
+ ),
+ "type": "integer",
+ "sensitive": False,
+ "required": False,
+ "restart_required": False,
+ },
}
diff --git a/docs/ConfigurationGuide.md b/docs/ConfigurationGuide.md
index 5c4dc3a0..863afb09 100644
--- a/docs/ConfigurationGuide.md
+++ b/docs/ConfigurationGuide.md
@@ -195,6 +195,16 @@ Admins can assign ownership of documents to any user:
The `DEFAULT_OWNER_ID` setting can also be configured via the Settings page, which provides an
autocomplete field that searches existing users by substring.
+### Subscriptions & Upload Quotas
+
+DocuElevate supports configurable subscription plans with per-user upload quotas enforced at upload time.
+Plans are managed via the **Plan Designer** at `/admin/plans`. The following global setting controls the
+default overage buffer applied across all plans.
+
+| **Variable** | **Description** | **Default** |
+|----------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|-------------|
+| `SUBSCRIPTION_OVERAGE_PERCENT` | Soft-limit overage buffer in percent (0–200). The announced monthly quota is multiplied by `(1 + percent/100)` for actual enforcement. E.g. `20` means a 150-doc/month plan enforces at 180 docs (150 × 1.20). Set `0` to enforce exactly at the announced limit. Per-plan `overage_percent` configured in the Plan Designer overrides this global default. | `20` |
+
### Security Headers
DocuElevate supports HTTP security headers to improve browser-side security. **These headers are disabled by default** since most deployments use a reverse proxy (Traefik, Nginx, etc.) that already adds them. Enable only if deploying directly without a reverse proxy. See [Deployment Guide - Security Headers](DeploymentGuide.md#security-headers) for detailed configuration examples.