chore: add @doist/todoist-ai
dependency to package.json اول دفعة من التطوير
This commit is contained in:
@@ -77,6 +77,7 @@ def _serialize_user(row: dict | None) -> dict | None:
|
||||
"role": _resolve_row_role(row),
|
||||
"is_allowlisted_admin": is_allowlisted_admin_email(row.get("email")),
|
||||
"created_at": row.get("created_at"),
|
||||
"welcome_bonus_available": int(row.get("welcome_bonus_used", 0)) == 0,
|
||||
}
|
||||
|
||||
|
||||
@@ -252,6 +253,18 @@ def _init_postgres_tables(conn):
|
||||
"ALTER TABLE usage_events ADD COLUMN cost_points INTEGER NOT NULL DEFAULT 1"
|
||||
)
|
||||
|
||||
# Add quoted_credits column to usage_events if missing
|
||||
if not _column_exists(conn, "usage_events", "quoted_credits"):
|
||||
cursor.execute(
|
||||
"ALTER TABLE usage_events ADD COLUMN quoted_credits INTEGER"
|
||||
)
|
||||
|
||||
# Add welcome_bonus_used flag to users if missing
|
||||
if not _column_exists(conn, "users", "welcome_bonus_used"):
|
||||
cursor.execute(
|
||||
"ALTER TABLE users ADD COLUMN welcome_bonus_used INTEGER NOT NULL DEFAULT 0"
|
||||
)
|
||||
|
||||
|
||||
def _init_sqlite_tables(conn):
|
||||
conn.executescript(
|
||||
@@ -366,6 +379,10 @@ def _init_sqlite_tables(conn):
|
||||
conn.execute("ALTER TABLE users ADD COLUMN role TEXT NOT NULL DEFAULT 'user'")
|
||||
if not _column_exists(conn, "usage_events", "cost_points"):
|
||||
conn.execute("ALTER TABLE usage_events ADD COLUMN cost_points INTEGER NOT NULL DEFAULT 1")
|
||||
if not _column_exists(conn, "usage_events", "quoted_credits"):
|
||||
conn.execute("ALTER TABLE usage_events ADD COLUMN quoted_credits INTEGER")
|
||||
if not _column_exists(conn, "users", "welcome_bonus_used"):
|
||||
conn.execute("ALTER TABLE users ADD COLUMN welcome_bonus_used INTEGER NOT NULL DEFAULT 0")
|
||||
|
||||
|
||||
def create_user(email: str, password: str) -> dict:
|
||||
@@ -398,9 +415,9 @@ def create_user(email: str, password: str) -> dict:
|
||||
user_id = cursor.lastrowid
|
||||
|
||||
row_sql = (
|
||||
"SELECT id, email, plan, role, created_at FROM users WHERE id = %s"
|
||||
"SELECT id, email, plan, role, created_at, welcome_bonus_used FROM users WHERE id = %s"
|
||||
if is_postgres()
|
||||
else "SELECT id, email, plan, role, created_at FROM users WHERE id = ?"
|
||||
else "SELECT id, email, plan, role, created_at, welcome_bonus_used FROM users WHERE id = ?"
|
||||
)
|
||||
cursor2 = execute_query(conn, row_sql, (user_id,))
|
||||
row = cursor2.fetchone()
|
||||
@@ -435,9 +452,9 @@ def authenticate_user(email: str, password: str) -> dict | None:
|
||||
def get_user_by_id(user_id: int) -> dict | None:
|
||||
with db_connection() as conn:
|
||||
sql = (
|
||||
"SELECT id, email, plan, role, created_at FROM users WHERE id = %s"
|
||||
"SELECT id, email, plan, role, created_at, welcome_bonus_used FROM users WHERE id = %s"
|
||||
if is_postgres()
|
||||
else "SELECT id, email, plan, role, created_at FROM users WHERE id = ?"
|
||||
else "SELECT id, email, plan, role, created_at, welcome_bonus_used FROM users WHERE id = ?"
|
||||
)
|
||||
cursor = execute_query(conn, sql, (user_id,))
|
||||
row = cursor.fetchone()
|
||||
@@ -485,9 +502,9 @@ def set_user_role(user_id: int, role: str) -> dict | None:
|
||||
execute_query(conn, sql, (normalized_role, _utc_now(), user_id))
|
||||
|
||||
sql2 = (
|
||||
"SELECT id, email, plan, role, created_at FROM users WHERE id = %s"
|
||||
"SELECT id, email, plan, role, created_at, welcome_bonus_used FROM users WHERE id = %s"
|
||||
if is_postgres()
|
||||
else "SELECT id, email, plan, role, created_at FROM users WHERE id = ?"
|
||||
else "SELECT id, email, plan, role, created_at, welcome_bonus_used FROM users WHERE id = ?"
|
||||
)
|
||||
cursor = execute_query(conn, sql2, (user_id,))
|
||||
row = cursor.fetchone()
|
||||
@@ -518,9 +535,9 @@ def update_user_plan(user_id: int, plan: str) -> dict | None:
|
||||
execute_query(conn, sql, (normalized_plan, _utc_now(), user_id))
|
||||
|
||||
sql2 = (
|
||||
"SELECT id, email, plan, role, created_at FROM users WHERE id = %s"
|
||||
"SELECT id, email, plan, role, created_at, welcome_bonus_used FROM users WHERE id = %s"
|
||||
if is_postgres()
|
||||
else "SELECT id, email, plan, role, created_at FROM users WHERE id = ?"
|
||||
else "SELECT id, email, plan, role, created_at, welcome_bonus_used FROM users WHERE id = ?"
|
||||
)
|
||||
cursor = execute_query(conn, sql2, (user_id,))
|
||||
row = cursor.fetchone()
|
||||
@@ -884,6 +901,7 @@ def record_usage_event(
|
||||
event_type: str,
|
||||
api_key_id: int | None = None,
|
||||
cost_points: int = 1,
|
||||
quoted_credits: int | None = None,
|
||||
):
|
||||
if user_id is None:
|
||||
return
|
||||
@@ -893,17 +911,17 @@ def record_usage_event(
|
||||
"""
|
||||
INSERT INTO usage_events (
|
||||
user_id, api_key_id, source, tool, task_id,
|
||||
event_type, created_at, period_month, cost_points
|
||||
event_type, created_at, period_month, cost_points, quoted_credits
|
||||
)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
|
||||
"""
|
||||
if is_postgres()
|
||||
else """
|
||||
INSERT INTO usage_events (
|
||||
user_id, api_key_id, source, tool, task_id,
|
||||
event_type, created_at, period_month, cost_points
|
||||
event_type, created_at, period_month, cost_points, quoted_credits
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
"""
|
||||
)
|
||||
execute_query(
|
||||
@@ -919,6 +937,7 @@ def record_usage_event(
|
||||
_utc_now(),
|
||||
get_current_period_month(),
|
||||
cost_points,
|
||||
quoted_credits,
|
||||
),
|
||||
)
|
||||
|
||||
@@ -982,9 +1001,9 @@ def get_user_by_email(email: str) -> dict | None:
|
||||
email = _normalize_email(email)
|
||||
with db_connection() as conn:
|
||||
sql = (
|
||||
"SELECT id, email, plan, role, created_at FROM users WHERE email = %s"
|
||||
"SELECT id, email, plan, role, created_at, welcome_bonus_used FROM users WHERE email = %s"
|
||||
if is_postgres()
|
||||
else "SELECT id, email, plan, role, created_at FROM users WHERE email = ?"
|
||||
else "SELECT id, email, plan, role, created_at, welcome_bonus_used FROM users WHERE email = ?"
|
||||
)
|
||||
cursor = execute_query(conn, sql, (email,))
|
||||
row = row_to_dict(cursor.fetchone())
|
||||
@@ -1087,3 +1106,32 @@ def log_file_event(
|
||||
else "INSERT INTO file_events (event_type, file_path, detail, created_at) VALUES (?, ?, ?, ?)"
|
||||
)
|
||||
execute_query(conn, sql, (event_type, file_path, detail, _utc_now()))
|
||||
|
||||
|
||||
# ── Welcome-bonus helpers ───────────────────────────────────────
|
||||
|
||||
def is_welcome_bonus_available(user_id: int) -> bool:
|
||||
"""Return True if the user has never used their welcome bonus."""
|
||||
with db_connection() as conn:
|
||||
sql = (
|
||||
"SELECT welcome_bonus_used FROM users WHERE id = %s"
|
||||
if is_postgres()
|
||||
else "SELECT welcome_bonus_used FROM users WHERE id = ?"
|
||||
)
|
||||
cursor = execute_query(conn, sql, (user_id,))
|
||||
row = row_to_dict(cursor.fetchone())
|
||||
if row is None:
|
||||
return False
|
||||
return int(row.get("welcome_bonus_used", 0)) == 0
|
||||
|
||||
|
||||
def consume_welcome_bonus(user_id: int) -> bool:
|
||||
"""Mark the welcome bonus as used. Returns True if it was actually consumed."""
|
||||
with db_connection() as conn:
|
||||
sql = (
|
||||
"UPDATE users SET welcome_bonus_used = 1, updated_at = %s WHERE id = %s AND welcome_bonus_used = 0"
|
||||
if is_postgres()
|
||||
else "UPDATE users SET welcome_bonus_used = 1, updated_at = ? WHERE id = ? AND welcome_bonus_used = 0"
|
||||
)
|
||||
cursor = execute_query(conn, sql, (_utc_now(), user_id))
|
||||
return cursor.rowcount > 0
|
||||
|
||||
@@ -4,12 +4,19 @@ Every tool has a credit cost. Lighter tools cost 1 credit, heavier
|
||||
server-side conversions cost 2, CPU/ML-intensive tools cost 3,
|
||||
and AI-powered tools cost 5+.
|
||||
|
||||
Heavy and AI tools use dynamic pricing based on file/request size.
|
||||
Light and medium tools keep a fixed cost per invocation.
|
||||
|
||||
This module is the single source of truth for all credit-related
|
||||
constants consumed by policy_service, credit_service, and the
|
||||
frontend config endpoint.
|
||||
constants consumed by policy_service, credit_service, quote_service,
|
||||
and the frontend config endpoint.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
|
||||
# ── Credit allocations per rolling 30-day window ────────────────
|
||||
FREE_CREDITS_PER_WINDOW = int(os.getenv("FREE_CREDITS_PER_WINDOW", "50"))
|
||||
@@ -29,6 +36,98 @@ TIER_MEDIUM = 2 # Server-side conversion (LibreOffice, Ghostscript, etc.)
|
||||
TIER_HEAVY = 3 # CPU/ML-intensive (OCR, background removal, compression)
|
||||
TIER_AI = 5 # AI-powered tools (LLM API calls)
|
||||
|
||||
# ── Dynamic pricing model ──────────────────────────────────────
|
||||
# Tools marked as DYNAMIC_PRICING_TIERS use size-based cost instead of
|
||||
# fixed tier cost. The formula is:
|
||||
# cost = base + ceil(file_size_kb / step_kb) * per_step
|
||||
# capped at max_credits. AI tools also add an estimated-tokens surcharge.
|
||||
|
||||
DYNAMIC_PRICING_TIERS: set[str] = {"heavy", "ai"}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DynamicPricingRule:
|
||||
"""Size-based pricing rule for one tool family."""
|
||||
|
||||
base: int # minimum credits charged
|
||||
step_kb: int # every `step_kb` KB adds `per_step` credits
|
||||
per_step: int # credits added per step
|
||||
max_credits: int # hard cap on credits per invocation
|
||||
|
||||
# AI-specific: extra credits per estimated 1 000 input tokens
|
||||
token_step: int = 0 # 0 means no token surcharge
|
||||
per_token_step: int = 0
|
||||
|
||||
|
||||
# Default rules per tier — individual tools can override via
|
||||
# TOOL_DYNAMIC_OVERRIDES below.
|
||||
HEAVY_DEFAULT_RULE = DynamicPricingRule(
|
||||
base=3, step_kb=500, per_step=1, max_credits=10,
|
||||
)
|
||||
AI_DEFAULT_RULE = DynamicPricingRule(
|
||||
base=5, step_kb=200, per_step=1, max_credits=20,
|
||||
token_step=1000, per_token_step=1,
|
||||
)
|
||||
|
||||
# Per-tool overrides (tool slug → rule).
|
||||
TOOL_DYNAMIC_OVERRIDES: dict[str, DynamicPricingRule] = {
|
||||
# Translation is heavier than chat/summarize
|
||||
"translate-pdf": DynamicPricingRule(
|
||||
base=6, step_kb=150, per_step=1, max_credits=25,
|
||||
token_step=1000, per_token_step=1,
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def _tier_label(cost: int) -> str:
|
||||
"""Return the tier family name for a fixed cost value."""
|
||||
if cost >= TIER_AI:
|
||||
return "ai"
|
||||
if cost >= TIER_HEAVY:
|
||||
return "heavy"
|
||||
if cost >= TIER_MEDIUM:
|
||||
return "medium"
|
||||
return "light"
|
||||
|
||||
|
||||
def _get_dynamic_rule(tool: str, tier: str) -> DynamicPricingRule | None:
|
||||
"""Return the dynamic pricing rule for *tool*, or None if fixed."""
|
||||
if tier not in DYNAMIC_PRICING_TIERS:
|
||||
return None
|
||||
if tool in TOOL_DYNAMIC_OVERRIDES:
|
||||
return TOOL_DYNAMIC_OVERRIDES[tool]
|
||||
return AI_DEFAULT_RULE if tier == "ai" else HEAVY_DEFAULT_RULE
|
||||
|
||||
|
||||
def is_dynamic_tool(tool: str) -> bool:
|
||||
"""Return True if *tool* uses size-based dynamic pricing."""
|
||||
base = TOOL_CREDIT_COSTS.get(tool, DEFAULT_CREDIT_COST)
|
||||
return _tier_label(base) in DYNAMIC_PRICING_TIERS
|
||||
|
||||
|
||||
def calculate_dynamic_cost(
|
||||
tool: str,
|
||||
file_size_kb: float = 0,
|
||||
estimated_tokens: int = 0,
|
||||
) -> int:
|
||||
"""Calculate the credit cost for a dynamic tool given size metrics.
|
||||
|
||||
For fixed-price tools this returns the static tier cost unchanged.
|
||||
"""
|
||||
base_cost = TOOL_CREDIT_COSTS.get(tool, DEFAULT_CREDIT_COST)
|
||||
tier = _tier_label(base_cost)
|
||||
rule = _get_dynamic_rule(tool, tier)
|
||||
if rule is None:
|
||||
return base_cost
|
||||
|
||||
size_surcharge = math.ceil(max(0, file_size_kb) / rule.step_kb) * rule.per_step
|
||||
token_surcharge = 0
|
||||
if rule.token_step and estimated_tokens > 0:
|
||||
token_surcharge = math.ceil(estimated_tokens / rule.token_step) * rule.per_token_step
|
||||
|
||||
total = rule.base + size_surcharge + token_surcharge
|
||||
return min(total, rule.max_credits)
|
||||
|
||||
# ── Per-tool credit costs ───────────────────────────────────────
|
||||
# Keys match the `tool` parameter passed to record_usage_event / routes.
|
||||
TOOL_CREDIT_COSTS: dict[str, int] = {
|
||||
@@ -108,7 +207,11 @@ DEFAULT_CREDIT_COST = TIER_LIGHT
|
||||
|
||||
|
||||
def get_tool_credit_cost(tool: str) -> int:
|
||||
"""Return the credit cost for a given tool slug."""
|
||||
"""Return the *fixed* credit cost for a given tool slug.
|
||||
|
||||
For dynamic tools this is the base/minimum cost.
|
||||
Use :func:`calculate_dynamic_cost` when file size is known.
|
||||
"""
|
||||
return TOOL_CREDIT_COSTS.get(tool, DEFAULT_CREDIT_COST)
|
||||
|
||||
|
||||
@@ -120,3 +223,29 @@ def get_credits_for_plan(plan: str) -> int:
|
||||
def get_all_tool_costs() -> dict[str, int]:
|
||||
"""Return the full cost registry — used by the config API endpoint."""
|
||||
return dict(TOOL_CREDIT_COSTS)
|
||||
|
||||
|
||||
def get_dynamic_tools_info() -> dict[str, dict]:
|
||||
"""Return metadata about tools that use dynamic pricing.
|
||||
|
||||
Used by the config/credit-info endpoint so the frontend can display
|
||||
"price varies by file size" for these tools.
|
||||
"""
|
||||
result: dict[str, dict] = {}
|
||||
seen: set[str] = set()
|
||||
for slug, cost in TOOL_CREDIT_COSTS.items():
|
||||
tier = _tier_label(cost)
|
||||
rule = _get_dynamic_rule(slug, tier)
|
||||
if rule is None:
|
||||
continue
|
||||
if slug in seen:
|
||||
continue
|
||||
seen.add(slug)
|
||||
result[slug] = {
|
||||
"base": rule.base,
|
||||
"step_kb": rule.step_kb,
|
||||
"per_step": rule.per_step,
|
||||
"max_credits": rule.max_credits,
|
||||
"has_token_surcharge": rule.token_step > 0,
|
||||
}
|
||||
return result
|
||||
|
||||
@@ -219,19 +219,26 @@ def deduct_credits(user_id: int, plan: str, tool: str) -> int:
|
||||
Raises ValueError if insufficient credits.
|
||||
"""
|
||||
cost = get_tool_credit_cost(tool)
|
||||
return deduct_credits_quoted(user_id, plan, cost)
|
||||
|
||||
|
||||
def deduct_credits_quoted(user_id: int, plan: str, cost: int) -> int:
|
||||
"""Deduct an explicit credit amount from the user's window.
|
||||
|
||||
Used by the quote engine to deduct a pre-calculated (possibly dynamic)
|
||||
cost rather than looking up the fixed tier cost.
|
||||
Raises ValueError if insufficient credits.
|
||||
"""
|
||||
with db_connection() as conn:
|
||||
# Ensure window is current
|
||||
window = _get_window(conn, user_id)
|
||||
if window is None or _is_window_expired(window.get("window_end_at", "")):
|
||||
# get_or_create handles reset
|
||||
pass
|
||||
window = get_or_create_credit_window(user_id, plan)
|
||||
|
||||
balance = window["credits_allocated"] - window["credits_used"]
|
||||
if balance < cost:
|
||||
raise ValueError(
|
||||
f"Insufficient credits: {balance} remaining, {cost} required for {tool}."
|
||||
f"Insufficient credits: {balance} remaining, {cost} required."
|
||||
)
|
||||
|
||||
sql = (
|
||||
|
||||
@@ -29,6 +29,7 @@ from app.services.guest_budget_service import (
|
||||
assert_guest_budget_available,
|
||||
record_guest_usage,
|
||||
)
|
||||
from app.services.quote_service import CreditQuote, fulfill_quote
|
||||
from app.utils.auth import get_current_user_id, logout_user_session
|
||||
from app.utils.auth import has_session_task_access, remember_task_access
|
||||
from app.utils.file_validator import validate_file
|
||||
@@ -223,29 +224,48 @@ def assert_quota_available(actor: ActorContext, tool: str | None = None):
|
||||
raise PolicyError("Your monthly API quota has been reached.", 429)
|
||||
|
||||
|
||||
def record_accepted_usage(actor: ActorContext, tool: str, celery_task_id: str):
|
||||
"""Record one accepted usage event and deduct credits after task dispatch."""
|
||||
def record_accepted_usage(actor: ActorContext, tool: str, celery_task_id: str, quote: CreditQuote | None = None):
|
||||
"""Record one accepted usage event and deduct credits after task dispatch.
|
||||
|
||||
When *quote* is provided the quote engine handles deduction (supports
|
||||
dynamic pricing and welcome bonus). Without a quote, falls back to the
|
||||
legacy fixed-cost deduction path.
|
||||
"""
|
||||
if actor.source == "web":
|
||||
remember_task_access(celery_task_id)
|
||||
|
||||
cost = get_tool_credit_cost(tool)
|
||||
charged = 0
|
||||
|
||||
# Deduct credits from the rolling window (registered users only)
|
||||
if actor.user_id is not None and actor.source == "web":
|
||||
try:
|
||||
deduct_credits(actor.user_id, actor.plan, tool)
|
||||
except ValueError:
|
||||
# Balance check should have caught this in assert_quota_available.
|
||||
# Log but don't block — the usage event is the source of truth.
|
||||
import logging
|
||||
logging.getLogger(__name__).warning(
|
||||
"Credit deduction failed for user %d tool %s (insufficient balance at record time)",
|
||||
actor.user_id,
|
||||
tool,
|
||||
)
|
||||
if quote is not None:
|
||||
try:
|
||||
charged = fulfill_quote(quote, actor.user_id, actor.plan)
|
||||
except ValueError:
|
||||
import logging
|
||||
logging.getLogger(__name__).warning(
|
||||
"Quote fulfillment failed for user %d tool %s",
|
||||
actor.user_id,
|
||||
tool,
|
||||
)
|
||||
charged = quote.charged_credits
|
||||
else:
|
||||
cost = get_tool_credit_cost(tool)
|
||||
try:
|
||||
deduct_credits(actor.user_id, actor.plan, tool)
|
||||
charged = cost
|
||||
except ValueError:
|
||||
import logging
|
||||
logging.getLogger(__name__).warning(
|
||||
"Credit deduction failed for user %d tool %s (insufficient balance at record time)",
|
||||
actor.user_id,
|
||||
tool,
|
||||
)
|
||||
charged = cost
|
||||
elif actor.user_id is None and actor.source == "web":
|
||||
# Record guest demo usage
|
||||
record_guest_usage()
|
||||
charged = get_tool_credit_cost(tool)
|
||||
|
||||
record_usage_event(
|
||||
user_id=actor.user_id,
|
||||
@@ -254,7 +274,8 @@ def record_accepted_usage(actor: ActorContext, tool: str, celery_task_id: str):
|
||||
task_id=celery_task_id,
|
||||
event_type="accepted",
|
||||
api_key_id=actor.api_key_id,
|
||||
cost_points=cost,
|
||||
cost_points=charged,
|
||||
quoted_credits=quote.quoted_credits if quote else None,
|
||||
)
|
||||
|
||||
|
||||
|
||||
211
backend/app/services/quote_service.py
Normal file
211
backend/app/services/quote_service.py
Normal file
@@ -0,0 +1,211 @@
|
||||
"""Central credit-quote engine — calculate and lock a price before dispatch.
|
||||
|
||||
The quote engine sits between route handlers and the credit/policy layer.
|
||||
It produces a ``CreditQuote`` that is:
|
||||
1. Shown to the user in the 202 response.
|
||||
2. Used to deduct credits (instead of the old fixed-cost path).
|
||||
3. Stored in usage_events.quoted_credits for audit.
|
||||
|
||||
Light/medium tools still get a fixed quote (== tier cost, no size factor).
|
||||
Heavy and AI tools get a dynamic quote based on file size and estimated
|
||||
tokens (for AI tools).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
|
||||
from app.services.account_service import (
|
||||
consume_welcome_bonus,
|
||||
is_welcome_bonus_available,
|
||||
)
|
||||
from app.services.credit_config import (
|
||||
calculate_dynamic_cost,
|
||||
get_tool_credit_cost,
|
||||
is_dynamic_tool,
|
||||
)
|
||||
from app.services.credit_service import (
|
||||
deduct_credits_quoted,
|
||||
get_rolling_balance,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CreditQuote:
|
||||
"""An immutable, pre-dispatch credit quote."""
|
||||
|
||||
tool: str
|
||||
base_cost: int # fixed tier cost
|
||||
quoted_credits: int # final cost after dynamic calc
|
||||
charged_credits: int # actual credits that will be deducted (0 if welcome bonus)
|
||||
welcome_bonus_applied: bool
|
||||
is_dynamic: bool
|
||||
file_size_kb: float
|
||||
estimated_tokens: int
|
||||
balance_before: int
|
||||
balance_after: int
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
"""Serialize for JSON response payloads."""
|
||||
return {
|
||||
"tool": self.tool,
|
||||
"quoted_credits": self.quoted_credits,
|
||||
"charged_credits": self.charged_credits,
|
||||
"welcome_bonus_applied": self.welcome_bonus_applied,
|
||||
"is_dynamic": self.is_dynamic,
|
||||
"file_size_kb": round(self.file_size_kb, 1),
|
||||
"balance_before": self.balance_before,
|
||||
"balance_after": self.balance_after,
|
||||
}
|
||||
|
||||
|
||||
class QuoteError(Exception):
|
||||
"""Quote could not be fulfilled (insufficient credits, etc.)."""
|
||||
|
||||
def __init__(self, message: str, status_code: int = 402):
|
||||
self.message = message
|
||||
self.status_code = status_code
|
||||
super().__init__(message)
|
||||
|
||||
|
||||
# ── Public API ──────────────────────────────────────────────────
|
||||
|
||||
def create_quote(
|
||||
user_id: int | None,
|
||||
plan: str,
|
||||
tool: str,
|
||||
file_size_kb: float = 0,
|
||||
estimated_tokens: int = 0,
|
||||
) -> CreditQuote:
|
||||
"""Build a quote for one tool invocation.
|
||||
|
||||
For anonymous users (user_id is None), returns a zero-cost quote
|
||||
because guest budget is handled separately by guest_budget_service.
|
||||
"""
|
||||
if user_id is None:
|
||||
base = get_tool_credit_cost(tool)
|
||||
return CreditQuote(
|
||||
tool=tool,
|
||||
base_cost=base,
|
||||
quoted_credits=base,
|
||||
charged_credits=0,
|
||||
welcome_bonus_applied=False,
|
||||
is_dynamic=False,
|
||||
file_size_kb=file_size_kb,
|
||||
estimated_tokens=estimated_tokens,
|
||||
balance_before=0,
|
||||
balance_after=0,
|
||||
)
|
||||
|
||||
dynamic = is_dynamic_tool(tool)
|
||||
base = get_tool_credit_cost(tool)
|
||||
|
||||
if dynamic:
|
||||
quoted = calculate_dynamic_cost(tool, file_size_kb, estimated_tokens)
|
||||
else:
|
||||
quoted = base
|
||||
|
||||
balance = get_rolling_balance(user_id, plan)
|
||||
|
||||
# Welcome bonus: first transaction is free
|
||||
bonus_available = is_welcome_bonus_available(user_id)
|
||||
if bonus_available:
|
||||
charged = 0
|
||||
else:
|
||||
charged = quoted
|
||||
|
||||
if charged > balance:
|
||||
raise QuoteError(
|
||||
f"Insufficient credits: {balance} remaining, {charged} required for {tool}.",
|
||||
402,
|
||||
)
|
||||
|
||||
balance_after = balance - charged
|
||||
|
||||
return CreditQuote(
|
||||
tool=tool,
|
||||
base_cost=base,
|
||||
quoted_credits=quoted,
|
||||
charged_credits=charged,
|
||||
welcome_bonus_applied=bonus_available,
|
||||
is_dynamic=dynamic,
|
||||
file_size_kb=file_size_kb,
|
||||
estimated_tokens=estimated_tokens,
|
||||
balance_before=balance,
|
||||
balance_after=balance_after,
|
||||
)
|
||||
|
||||
|
||||
def estimate_quote(
|
||||
user_id: int | None,
|
||||
plan: str,
|
||||
tool: str,
|
||||
file_size_kb: float = 0,
|
||||
estimated_tokens: int = 0,
|
||||
) -> dict:
|
||||
"""Return a non-binding estimate (for the frontend pre-upload panel).
|
||||
|
||||
Does NOT lock or deduct anything — purely informational.
|
||||
"""
|
||||
try:
|
||||
quote = create_quote(user_id, plan, tool, file_size_kb, estimated_tokens)
|
||||
result = quote.to_dict()
|
||||
result["affordable"] = True
|
||||
return result
|
||||
except QuoteError as exc:
|
||||
base = get_tool_credit_cost(tool)
|
||||
dynamic = is_dynamic_tool(tool)
|
||||
if dynamic:
|
||||
quoted = calculate_dynamic_cost(tool, file_size_kb, estimated_tokens)
|
||||
else:
|
||||
quoted = base
|
||||
balance = get_rolling_balance(user_id, plan) if user_id else 0
|
||||
return {
|
||||
"tool": tool,
|
||||
"quoted_credits": quoted,
|
||||
"charged_credits": quoted,
|
||||
"welcome_bonus_applied": False,
|
||||
"is_dynamic": dynamic,
|
||||
"file_size_kb": round(file_size_kb, 1),
|
||||
"balance_before": balance,
|
||||
"balance_after": balance - quoted,
|
||||
"affordable": False,
|
||||
"reason": exc.message,
|
||||
}
|
||||
|
||||
|
||||
def fulfill_quote(
|
||||
quote: CreditQuote,
|
||||
user_id: int,
|
||||
plan: str,
|
||||
) -> int:
|
||||
"""Deduct credits based on a locked quote. Returns credits charged.
|
||||
|
||||
If welcome bonus is applied, the bonus is consumed atomically and
|
||||
zero credits are deducted from the rolling window.
|
||||
"""
|
||||
if quote.welcome_bonus_applied:
|
||||
consumed = consume_welcome_bonus(user_id)
|
||||
if not consumed:
|
||||
# Race condition: bonus was consumed between quote and fulfill.
|
||||
# Fall back to normal deduction.
|
||||
logger.warning(
|
||||
"Welcome bonus race for user %d — falling back to normal deduction",
|
||||
user_id,
|
||||
)
|
||||
return deduct_credits_quoted(user_id, plan, quote.quoted_credits)
|
||||
logger.info(
|
||||
"Welcome bonus applied for user %d, tool=%s, cost=%d waived",
|
||||
user_id,
|
||||
quote.tool,
|
||||
quote.quoted_credits,
|
||||
)
|
||||
return 0
|
||||
|
||||
if quote.charged_credits == 0:
|
||||
return 0
|
||||
|
||||
return deduct_credits_quoted(user_id, plan, quote.charged_credits)
|
||||
Reference in New Issue
Block a user