chore: plan dynamic plan designer feature
Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
This commit is contained in:
+159
-72
@@ -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 1–12."""
|
||||
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,
|
||||
)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user