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,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user