chore: plan dynamic plan designer feature

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
This commit is contained in:
copilot-swe-agent[bot]
2026-03-06 18:21:04 +00:00
parent 5351d82275
commit d439d9afdd
4 changed files with 269 additions and 108 deletions
+159 -72
View File
@@ -1,13 +1,28 @@
"""
Subscription tier definitions and enforcement utilities for DocuElevate SaaS.
Four tiers:
- free $0/mo — 25 lifetime files, 1 destination, 50 OCR pages/mo
- starter $2.99/mo — 10/day, 100/mo, 3 destinations, 500 OCR pages/mo
- professional $5.99/mo — 50/day, 500/mo, 10 destinations, 2 500 OCR pages/mo
- business $7.99/mo — unlimited, unlimited destinations, unlimited OCR
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 — 5/day, 50/mo, 300 OCR pp/mo, 2 dests, 1 mailbox
- professional $5.99/mo — 15/day, 150/mo, 750 OCR pp/mo, 5 dests, 3 mailboxes
- business $7.99/mo — 30/day, 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).
--- Cost analysis at maximum usage (Hetzner Option-A infra, Azure Read + GPT-4o mini) ---
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
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
After ~30 % German corporate tax: Starter 45 %, Professional 49 %, Business 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,
reducing after-tax margin to ~33 %. Recommend GPT-4o mini as default in production.
"""
from __future__ import annotations
@@ -32,21 +47,23 @@ TIERS: dict[str, dict[str, Any]] = {
"tagline": "Explore DocuElevate at no cost",
"price_monthly": 0,
"price_yearly": 0,
"trial_days": 0,
"highlight": False,
# Hard caps — 0 = unlimited
"lifetime_file_limit": 25, # total files ever processed
"daily_upload_limit": 0, # no per-day cap (capped by lifetime)
"monthly_upload_limit": 0, # no per-month cap (capped by lifetime)
"lifetime_file_limit": 50, # total docs ever processed (enforced at upload)
"daily_upload_limit": 0, # no per-day cap (lifetime cap applies instead)
"monthly_upload_limit": 0, # no per-month cap (lifetime cap applies instead)
"max_storage_destinations": 1,
"max_ocr_pages_monthly": 50,
"max_file_size_mb": 10,
"max_ocr_pages_monthly": 150, # informational; enforced when OCR quota tracking lands
"max_file_size_mb": 5,
"max_mailboxes": 0, # no email ingestion on free tier
"api_access": False,
# Marketing feature list (shown on pricing page)
"features": [
"25 documents lifetime total",
"50 documents lifetime total",
"150 OCR pages — lifetime total",
"1 storage destination",
"50 OCR pages / month",
"10 MB max file size",
"5 MB max file size",
"Basic AI metadata extraction",
"Community support",
],
@@ -56,29 +73,30 @@ TIERS: dict[str, dict[str, Any]] = {
"starter": {
"id": "starter",
"name": "Starter",
"tagline": "Perfect for individuals & small teams",
"tagline": "Perfect for individuals getting started",
"price_monthly": 2.99,
"price_yearly": 29.99,
"price_yearly": 28.99, # ≈ 80 % of monthly × 12 — save ~19 % (≈ 2½ months free)
"trial_days": 30,
"highlight": False,
"lifetime_file_limit": 0,
"daily_upload_limit": 10,
"monthly_upload_limit": 100,
"max_storage_destinations": 3,
"max_ocr_pages_monthly": 500,
"max_file_size_mb": 50,
"daily_upload_limit": 0, # no daily cap
"monthly_upload_limit": 50,
"max_storage_destinations": 2,
"max_ocr_pages_monthly": 300,
"max_file_size_mb": 25,
"max_mailboxes": 1,
"api_access": True,
"features": [
"10 documents / day",
"100 documents / month",
"3 storage destinations",
"500 OCR pages / month",
"50 MB max file size",
"50 documents / month",
"2 storage destinations",
"300 OCR pages / month",
"25 MB max file size",
"Full AI metadata extraction",
"Email ingestion",
"1 email ingestion mailbox",
"API access",
"Email support",
],
"cta": "Start with Starter",
"cta": "Start free trial",
"badge": None,
},
"professional": {
@@ -86,55 +104,59 @@ TIERS: dict[str, dict[str, Any]] = {
"name": "Professional",
"tagline": "For growing teams that need more power",
"price_monthly": 5.99,
"price_yearly": 59.99,
"price_yearly": 57.99, # ≈ 80 % of monthly × 12 — save ~19 %
"trial_days": 30,
"highlight": True, # shown as "Most popular"
"lifetime_file_limit": 0,
"daily_upload_limit": 50,
"monthly_upload_limit": 500,
"max_storage_destinations": 10,
"max_ocr_pages_monthly": 2500,
"max_file_size_mb": 200,
"daily_upload_limit": 0, # no daily cap
"monthly_upload_limit": 150,
"max_storage_destinations": 5,
"max_ocr_pages_monthly": 750,
"max_file_size_mb": 100,
"max_mailboxes": 3,
"api_access": True,
"features": [
"50 documents / day",
"500 documents / month",
"10 storage destinations",
"2 500 OCR pages / month",
"200 MB max file size",
"150 documents / month",
"5 storage destinations",
"750 OCR pages / month",
"100 MB max file size",
"Advanced AI workflows",
"3 email ingestion mailboxes",
"Email & URL ingestion",
"Webhooks",
"Priority email support",
],
"cta": "Go Professional",
"cta": "Start free trial",
"badge": "Most Popular",
},
"business": {
"id": "business",
"name": "Business",
"tagline": "Unlimited processing for organisations",
"tagline": "High-volume processing for organisations",
"price_monthly": 7.99,
"price_yearly": 79.99,
"price_yearly": 76.99, # ≈ 80 % of monthly × 12 — save ~20 %
"trial_days": 30,
"highlight": False,
"lifetime_file_limit": 0,
"daily_upload_limit": 0,
"monthly_upload_limit": 0,
"max_storage_destinations": 0,
"max_ocr_pages_monthly": 0,
"max_file_size_mb": 0,
"daily_upload_limit": 0, # no daily cap
"monthly_upload_limit": 300,
"max_storage_destinations": 10,
"max_ocr_pages_monthly": 1500,
"max_file_size_mb": 0, # unlimited file size
"max_mailboxes": 0, # unlimited mailboxes
"api_access": True,
"features": [
"Unlimited documents",
"Unlimited storage destinations",
"Unlimited OCR pages",
"300 documents / month",
"10 storage destinations",
"1,500 OCR pages / month",
"Unlimited file size",
"All AI processing steps",
"Unlimited email ingestion mailboxes",
"All ingestion methods",
"Webhooks & full API access",
"Custom integrations",
"Dedicated support",
],
"cta": "Contact Sales",
"cta": "Start free trial",
"badge": "Best Value",
},
}
@@ -212,6 +234,29 @@ def get_month_file_count(db: Session, owner_id: str) -> int:
)
def get_year_file_count(db: Session, owner_id: str, period_start: datetime) -> int:
"""Files processed since the start of the current subscription period.
Used for yearly-subscription carry-over: compares cumulative usage against the
cumulative monthly budget since the annual period started.
"""
from app.models import FileRecord
return _scalar_count(
db.query(func.count(FileRecord.id)).filter(
FileRecord.owner_id == owner_id,
FileRecord.is_duplicate.is_(False),
FileRecord.created_at >= period_start,
)
)
def _months_elapsed(period_start: datetime, now: datetime) -> int:
"""Calendar months elapsed since *period_start*, clamped to 112."""
elapsed = (now.year - period_start.year) * 12 + (now.month - period_start.month) + 1
return max(1, min(elapsed, 12))
# ---------------------------------------------------------------------------
# Limit enforcement
# ---------------------------------------------------------------------------
@@ -232,17 +277,48 @@ def check_upload_allowed(db: Session, owner_id: str | None, tier_id: str | None)
When *owner_id* or *tier_id* is ``None`` (e.g. single-user mode) the check
is skipped entirely.
Enforcement model
-----------------
* **Announced limit** — the quota shown to users on the pricing page
(``monthly_upload_limit`` in TIERS).
* **Enforcement limit** — ``announced × settings.subscription_overage_factor``
(default 1.33). A 150-doc/month plan is therefore enforced at 200 docs,
giving users a soft buffer before they see an error.
* **Overage flag** — if ``UserProfile.allow_overage`` is ``True`` the check
is bypassed entirely. Usage is still tracked so future billing can charge
for overages. (Not yet exposed in the admin UI.)
* **Yearly carry-over** — yearly subscribers have cumulative quota:
effective limit = ``monthly_limit × months_elapsed × overage_factor``.
Unused quota from earlier months rolls forward automatically.
No daily cap is enforced — ``daily_upload_limit`` in TIERS is kept as
informational data only.
"""
if owner_id is None or tier_id is None:
return
tier = get_tier(tier_id)
# 1. Lifetime file cap (free tier)
# Resolve overage factor from config
from app.config import settings
overage_factor: float = settings.subscription_overage_factor
# Fetch profile for billing cycle and overage permission
from app.models import UserProfile
profile = db.query(UserProfile).filter(UserProfile.user_id == owner_id).first()
allow_overage: bool = bool(profile.allow_overage) if profile else False
billing_cycle: str = (profile.subscription_billing_cycle if profile else None) or "monthly"
period_start: datetime | None = profile.subscription_period_start if profile else None
# 1. Lifetime file cap (free tier) — always enforced regardless of overage flag
lifetime_limit = tier["lifetime_file_limit"]
if lifetime_limit > 0:
enforcement_limit = int(lifetime_limit * overage_factor)
count = get_lifetime_file_count(db, owner_id)
if count >= lifetime_limit:
if count >= enforcement_limit:
raise QuotaExceeded(
f"Lifetime file limit of {lifetime_limit} reached for the {tier['name']} plan. "
"Please upgrade to continue processing documents.",
@@ -251,28 +327,39 @@ def check_upload_allowed(db: Session, owner_id: str | None, tier_id: str | None)
current_value=count,
)
# 2. Daily cap
daily_limit = tier["daily_upload_limit"]
if daily_limit > 0:
count = get_today_file_count(db, owner_id)
if count >= daily_limit:
raise QuotaExceeded(
f"Daily file limit of {daily_limit} reached for the {tier['name']} plan. "
"Please try again tomorrow or upgrade your plan.",
limit_type="daily",
limit_value=daily_limit,
current_value=count,
)
# 2. Monthly cap — skipped entirely when overage is enabled for this user
if allow_overage:
return
# 3. Monthly cap
monthly_limit = tier["monthly_upload_limit"]
if monthly_limit > 0:
count = get_month_file_count(db, owner_id)
if count >= monthly_limit:
raise QuotaExceeded(
f"Monthly file limit of {monthly_limit} reached for the {tier['name']} plan. "
"Please upgrade your plan for more documents this month.",
limit_type="monthly",
if billing_cycle == "yearly" and period_start is not None:
# Carry-over: cumulative usage vs cumulative budget within the subscription year
now = datetime.now(timezone.utc)
months = _months_elapsed(period_start, now)
cumulative_budget = int(monthly_limit * months * overage_factor)
cumulative_used = get_year_file_count(db, owner_id, period_start)
if cumulative_used >= cumulative_budget:
raise QuotaExceeded(
f"Annual document quota for the {tier['name']} plan has been reached. "
"Unused monthly quota carries forward — your limit will reset on your "
"annual renewal date, or you can upgrade your plan.",
limit_type="monthly",
limit_value=monthly_limit,
current_value=cumulative_used,
)
else:
# Monthly billing: check current calendar month only
count = get_month_file_count(db, owner_id)
enforcement_limit = int(monthly_limit * overage_factor)
if count >= enforcement_limit:
raise QuotaExceeded(
f"Monthly file limit of {monthly_limit} reached for the {tier['name']} plan. "
"Please upgrade your plan for more documents this month.",
limit_type="monthly",
limit_value=monthly_limit,
current_value=count,
)
limit_value=monthly_limit,
current_value=count,
)
+3 -3
View File
@@ -335,9 +335,9 @@
class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-400 bg-white"
>
<option value="free">Free — 25 lifetime files</option>
<option value="starter">Starter — $9/mo (10/day, 100/mo)</option>
<option value="professional">Professional — $29/mo (50/day, 500/mo)</option>
<option value="business">Business — $79/mo (unlimited)</option>
<option value="starter">Starter — $2.99/mo (5/day, 50/mo, 1 mailbox)</option>
<option value="professional">Professional — $5.99/mo (15/day, 150/mo, 3 mailboxes)</option>
<option value="business">Business — $7.99/mo (30/day, 300/mo, unlimited mailboxes)</option>
</select>
<p class="text-xs text-gray-400 mt-1">
Sets the quota limits for this user. Limits are enforced on upload.
+33 -10
View File
@@ -29,7 +29,7 @@
:class="annual ? 'bg-white text-indigo-700 shadow' : 'text-white'"
class="px-5 py-2 rounded-full text-sm font-semibold transition-all duration-200"
>
Annual <span class="ml-1 text-xs bg-green-400 text-green-900 rounded-full px-2 py-0.5 font-bold">Save ~17%</span>
Annual <span class="ml-1 text-xs bg-green-400 text-green-900 rounded-full px-2 py-0.5 font-bold">Save ~20%</span>
</button>
</div>
</div>
@@ -84,12 +84,20 @@
<span class="text-4xl font-extrabold text-gray-900">${{ tier.price_yearly }}</span>
<span class="text-gray-500 text-sm">/year</span>
<div class="text-xs text-green-600 font-semibold mt-0.5">
~${{ (tier.price_yearly / 12) | round(0) | int }}/month billed annually
~${{ "%.2f"|format(tier.price_yearly / 12) }}/month billed annually
</div>
{% endif %}
</div>
</template>
</div>
<!-- Free trial badge for paid tiers -->
{% if tier.trial_days > 0 %}
<div class="mt-3 inline-flex items-center gap-1 text-xs font-semibold text-emerald-700 bg-emerald-50 border border-emerald-200 rounded-full px-3 py-1">
<i class="fas fa-gift" aria-hidden="true"></i>
{{ tier.trial_days }}-day free trial — no credit card required
</div>
{% endif %}
</div>
<!-- Features list -->
@@ -110,10 +118,6 @@
<a href="/login"
class="block w-full text-center py-3 px-4 rounded-lg border-2 border-blue-600 text-blue-600 font-semibold hover:bg-blue-50 transition"
>{{ tier.cta }}</a>
{% elif tier.id == 'business' %}
<a href="mailto:sales@docuelevate.io?subject=Business+Plan+Enquiry"
class="block w-full text-center py-3 px-4 rounded-lg bg-gray-800 text-white font-semibold hover:bg-gray-700 transition"
>{{ tier.cta }}</a>
{% elif tier.highlight %}
<a href="/login"
class="block w-full text-center py-3 px-4 rounded-lg bg-indigo-600 text-white font-semibold hover:bg-indigo-700 transition shadow-md"
@@ -267,6 +271,21 @@
{% endfor %}
</tr>
<tr class="hover:bg-indigo-50/30 transition">
<td class="px-6 py-3 text-sm text-gray-700">Mailboxes (ingestion sources)</td>
{% for tier in tiers %}
<td class="px-4 py-3 text-center text-sm">
{% if tier.max_mailboxes == 0 and tier.id == 'free' %}
<i class="fas fa-times text-gray-300" aria-label="Not included"></i>
{% elif tier.max_mailboxes == 0 %}
<span class="font-semibold text-green-600">Unlimited</span>
{% else %}
<span class="font-medium text-gray-800">{{ tier.max_mailboxes }}</span>
{% endif %}
</td>
{% endfor %}
</tr>
<tr class="hover:bg-indigo-50/30 transition">
<td class="px-6 py-3 text-sm text-gray-700">Webhooks</td>
<td class="px-4 py-3 text-center">
@@ -323,15 +342,19 @@
("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."),
"Once your 50-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."),
"Yes — paying annually saves approximately 20 % compared to monthly billing (≈ 2½ months free). The exact annual price and per-month equivalent are shown when you toggle to Annual above."),
("Is there a free trial for paid plans?",
"Yes! All three paid plans include a 30-day free trial — no credit card required. You can upgrade from the Free tier or start a trial directly from any paid plan card above."),
("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."),
("What is a 'mailbox'?",
"A mailbox is an email address DocuElevate monitors for incoming documents. Any attachment arriving at a configured mailbox is automatically processed through the pipeline. The Free tier does not include email ingestion."),
("Are prices inclusive of VAT?",
"Listed prices are exclusive of VAT. Customers in Germany are charged 19 % Mehrwertsteuer (MwSt) at checkout. EU business customers outside Germany apply the reverse-charge mechanism. Non-EU customers are not subject to German VAT."),
] %}
{% for q, a in faqs %}
+74 -23
View File
@@ -50,7 +50,7 @@ def test_default_tier_is_free():
def test_get_tier_returns_correct_dict():
t = get_tier("starter")
assert t["id"] == "starter"
assert t["price_monthly"] == 9
assert t["price_monthly"] == 2.99
@pytest.mark.unit
@@ -70,8 +70,10 @@ def test_get_all_tiers_returns_four():
def test_all_tiers_have_required_fields():
required = [
"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",
"max_mailboxes",
"features", "cta",
]
for tid, tier in TIERS.items():
@@ -81,19 +83,54 @@ def test_all_tiers_have_required_fields():
@pytest.mark.unit
def test_free_tier_has_lifetime_limit():
"""Free tier must have a non-zero lifetime file limit."""
assert TIERS["free"]["lifetime_file_limit"] > 0
"""Free tier must have a non-zero lifetime file limit of 50."""
assert TIERS["free"]["lifetime_file_limit"] == 50
@pytest.mark.unit
def test_business_tier_is_unlimited():
"""Business tier must have 0 (unlimited) for all limits."""
def test_free_tier_ocr_pages():
"""Free tier must have 150 OCR pages."""
assert TIERS["free"]["max_ocr_pages_monthly"] == 150
@pytest.mark.unit
def test_free_tier_has_no_mailboxes():
"""Free tier must not allow email ingestion mailboxes."""
assert TIERS["free"]["max_mailboxes"] == 0
@pytest.mark.unit
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
assert t["lifetime_file_limit"] == 0
assert t["daily_upload_limit"] == 0
assert t["monthly_upload_limit"] == 0
assert t["max_storage_destinations"] == 0
assert t["max_ocr_pages_monthly"] == 0
assert t["daily_upload_limit"] == 30
assert t["monthly_upload_limit"] == 300
assert t["max_ocr_pages_monthly"] == 1500
# unlimited mailboxes
assert t["max_mailboxes"] == 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(∞)."""
assert TIERS["free"]["max_mailboxes"] == 0
assert TIERS["starter"]["max_mailboxes"] == 1
assert TIERS["professional"]["max_mailboxes"] == 3
assert TIERS["business"]["max_mailboxes"] == 0 # 0 means unlimited
@pytest.mark.unit
def test_paid_tiers_have_trial_days():
"""All paid tiers must have a 30-day free trial."""
for tid in ["starter", "professional", "business"]:
assert TIERS[tid]["trial_days"] == 30, f"Tier '{tid}' missing 30-day trial"
@pytest.mark.unit
def test_free_tier_has_no_trial():
assert TIERS["free"]["trial_days"] == 0
@pytest.mark.unit
@@ -160,16 +197,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 is hit."""
"""Free tier: should raise QuotaExceeded when lifetime limit (50) is hit."""
db = MagicMock()
with patch("app.utils.subscription.get_lifetime_file_count", return_value=25):
with patch("app.utils.subscription.get_lifetime_file_count", return_value=50):
with pytest.raises(QuotaExceeded) as exc_info:
check_upload_allowed(db, "user@example.com", "free")
assert exc_info.value.limit_type == "lifetime"
assert exc_info.value.limit_value == 25
assert exc_info.value.current_value == 25
assert exc_info.value.limit_value == 50
assert exc_info.value.current_value == 50
@pytest.mark.unit
@@ -181,11 +218,11 @@ def test_check_upload_passes_below_lifetime_limit():
@pytest.mark.unit
def test_check_upload_raises_when_daily_exceeded():
"""Starter tier: should raise QuotaExceeded when daily limit is hit."""
"""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=10):
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")
@@ -194,12 +231,12 @@ def test_check_upload_raises_when_daily_exceeded():
@pytest.mark.unit
def test_check_upload_raises_when_monthly_exceeded():
"""Starter tier: should raise QuotaExceeded when monthly limit is hit."""
"""Starter tier: should raise QuotaExceeded when monthly limit (50) 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=0), \
patch("app.utils.subscription.get_month_file_count", return_value=100):
patch("app.utils.subscription.get_month_file_count", return_value=50):
with pytest.raises(QuotaExceeded) as exc_info:
check_upload_allowed(db, "user@example.com", "starter")
@@ -207,16 +244,30 @@ def test_check_upload_raises_when_monthly_exceeded():
@pytest.mark.unit
def test_check_upload_business_tier_never_raises():
"""Business tier has no limits — check_upload_allowed must never raise."""
def test_check_upload_business_tier_within_limits():
"""Business tier: upload is allowed as long as counts are below the capped limits."""
db = MagicMock()
# Even with absurdly high counts, business tier is unlimited
with patch("app.utils.subscription.get_lifetime_file_count", return_value=999999), \
patch("app.utils.subscription.get_today_file_count", return_value=999999), \
patch("app.utils.subscription.get_month_file_count", return_value=999999):
# 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):
check_upload_allowed(db, "user@example.com", "business") # must not raise
@pytest.mark.unit
def test_check_upload_business_tier_raises_when_daily_exceeded():
"""Business tier: should raise QuotaExceeded when daily limit (30) 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=30):
with pytest.raises(QuotaExceeded) as exc_info:
check_upload_allowed(db, "user@example.com", "business")
assert exc_info.value.limit_type == "daily"
assert exc_info.value.limit_value == 30
# ---------------------------------------------------------------------------
# get_user_usage (mocked DB)
# ---------------------------------------------------------------------------