The file /home/ubuntu/pipecat/src/core/database.py has been updated successfully.

        # Serialize args with proper NULL handling (matching execute_batch)
        turso_args = []
        for arg in (args or []):
            if arg is None:
                turso_args.append({"type": "null"})
            elif isinstance(arg, dict) and "type" in arg:
                turso_args.append(arg)
            else:
                turso_args.append({"type": "text", "value": str(arg)})

"""Turso HTTP database client and query execution helpers."""

import asyncio
import time
from typing import Optional, List, Any, Union

from loguru import logger

from src.config import constants

DB_SLOW_QUERY_THRESHOLD_MS = 100
DB_CRITICAL_QUERY_THRESHOLD_MS = 500

CRITICAL_OPS = {
    "should_show_app_review": "Multiple queries at session end",
}

def _log_db_timing(operation: str, duration_ms: float, table: str = None, device_id: str = None, success: bool = True, rows: int = None):
    is_critical = operation in CRITICAL_OPS
    is_slow = duration_ms >= DB_SLOW_QUERY_THRESHOLD_MS
    is_very_slow = duration_ms >= DB_CRITICAL_QUERY_THRESHOLD_MS

    parts = [f"DB_{operation.upper()}: duration_ms={duration_ms:.2f}"]
    if table:
        parts.append(f"table={table}")
    if device_id:
        parts.append(f"device_id={device_id}")
    if rows is not None:
        parts.append(f"rows={rows}")
    parts.append(f"success={success}")

    if is_critical:
        parts.append("critical=true")
    if is_slow:
        parts.append(f"slow={'VERY_SLOW' if is_very_slow else 'SLOW'}")

    message = " ".join(parts)

    if not success:
        logger.error(message)
    elif is_very_slow:
        logger.warning(f"⚠ {message}")
    elif is_slow and is_critical:
        logger.warning(message)
    elif is_critical:
        logger.info(message)
    else:
        logger.debug(message)

def _extract_value(row: Union[list, dict], index_or_key: Union[int, str] = 0, value_type: str = "text") -> Optional[Any]:
    if row is None:
        return None

    if isinstance(row, list):
        idx = index_or_key if isinstance(index_or_key, int) else 0
        if len(row) <= idx:
            return None
        value_dict = row[idx] if isinstance(row[idx], dict) else None
        if value_dict and value_dict.get("type") == value_type:
            return value_dict.get("value")
        return None

    elif isinstance(row, dict):
        if isinstance(index_or_key, str):
            return row.get(index_or_key)
        return None

    return None

def _extract_int(row: Union[list, dict], index_or_key: Union[int, str] = 0, default: int = 0) -> int:
    value = _extract_value(row, index_or_key, "integer")
    if value is not None:
        try:
            return int(value)
        except (ValueError, TypeError):
            pass
    return default

def _extract_text(row: Union[list, dict], index_or_key: Union[int, str] = 0, default: str = None) -> Optional[str]:
    return _extract_value(row, index_or_key, "text") or default

class TursoHTTPClient:
    def __init__(self, url: str, token: str):
        import requests
        self.base_url = url.replace("libsql://", "https://")
        self.token = token
        self.api_url = f"{self.base_url}/v2/pipeline"
        self.headers = {
            "Authorization": f"Bearer {self.token}",
            "Content-Type": "application/json"
        }
        self.session = requests.Session()
        adapter = requests.adapters.HTTPAdapter(
            pool_connections=constants.HTTP_POOL_CONNECTIONS,
            pool_maxsize=constants.HTTP_POOL_MAXSIZE,
            pool_block=False
        )
        self.session.mount('https://', adapter)

    def execute(self, query_obj: dict):
        sql = query_obj.get("sql")
        args = query_obj.get("args", [])

        timeout = (constants.TURSO_CONNECT_TIMEOUT, constants.TURSO_READ_TIMEOUT)

        # Serialize args with proper NULL handling (matching execute_batch)
        turso_args = []
        for arg in (args or []):
            if arg is None:
                turso_args.append({"type": "null"})
            else:
                turso_args.append({"type": "text", "value": str(arg)})

        request_body = {
            "requests": [
                {
                    "type": "execute",
                    "stmt": {
                        "sql": sql,
                        "args": turso_args
                    }
                }
            ]
        }

        response = self.session.post(
            self.api_url,
            headers=self.headers,
            json=request_body,
            timeout=timeout
        )

        if response.status_code == 200:
            data = response.json()
            results = data.get('results', [])

            # Check for query-level errors (Turso returns HTTP 200 with error in body)
            if results and results[0].get('type') == 'error':
                error_msg = results[0].get('error', {}).get('message', 'Unknown query error')
                raise Exception(f"Turso query error: {error_msg}")

            result_data = results[0].get('response', {}).get('result', {}) if results else {}
            rows_affected = result_data.get('affected_row_count', 0)

            # Log write operations for debugging
            sql_upper = (sql or "").strip().upper()
            if sql_upper.startswith("INSERT") or sql_upper.startswith("UPDATE") or sql_upper.startswith("DELETE"):
                from loguru import logger
                logger.info(f"TURSO_WRITE: op={sql_upper.split()[0]} rows_affected={rows_affected} result_type={results[0].get('type') if results else 'empty'}")

            result = type('obj', (object,), {
                'rows': result_data.get('rows', []),
                'rows_affected': rows_affected,
            })
            return result
        else:
            raise Exception(f"HTTP {response.status_code}: {response.text}")

    def execute_batch(self, statements: List[dict]) -> bool:
        if not statements:
            return True

        timeout = (constants.TURSO_CONNECT_TIMEOUT, constants.TURSO_READ_TIMEOUT * 2)

        requests_list = []
        for stmt in statements:
            sql = stmt.get("sql")
            args = stmt.get("args", [])

            turso_args = []
            for arg in args:
                if arg is None:
                    turso_args.append({"type": "null"})
                else:
                    turso_args.append({"type": "text", "value": str(arg)})

            requests_list.append({
                "type": "execute",
                "stmt": {
                    "sql": sql,
                    "args": turso_args
                }
            })

        response = self.session.post(
            self.api_url,
            headers=self.headers,
            json={"requests": requests_list},
            timeout=timeout
        )

        if response.status_code == 200:
            return True
        else:
            raise Exception(f"HTTP {response.status_code}: {response.text}")

_turso_client: Optional[TursoHTTPClient] = None

def get_turso_client() -> Optional[TursoHTTPClient]:
    global _turso_client

    if _turso_client is None:
        try:
            if not constants.TURSO_DATABASE_URL or not constants.TURSO_AUTH_TOKEN:
                logger.warning("TURSO_DISABLED: reason=credentials_not_configured")
                return None

            _turso_client = TursoHTTPClient(constants.TURSO_DATABASE_URL, constants.TURSO_AUTH_TOKEN)
            logger.debug(f"Turso client initialized: {constants.TURSO_DATABASE_URL}")

        except ImportError as e:
            logger.error(f"TURSO_DEPENDENCY_MISSING: error={str(e)} error_type={type(e).__name__}")
            return None
        except Exception as e:
            logger.error(f"TURSO_CLIENT_INIT_FAILED: error={str(e)} error_type={type(e).__name__}")
            return None

    return _turso_client

async def execute_query(query_obj: dict, operation_name: str = "query"):
    client = get_turso_client()
    if client is None:
        raise Exception("Turso client not available")

    start_time = time.time()
    loop = asyncio.get_event_loop()
    try:
        result = await loop.run_in_executor(None, client.execute, query_obj)
        duration_ms = (time.time() - start_time) * 1000
        rows = len(result.rows) if result and hasattr(result, 'rows') else 0
        _log_db_timing(operation_name, duration_ms, rows=rows, success=True)
        return result
    except Exception:
        duration_ms = (time.time() - start_time) * 1000
        _log_db_timing(operation_name, duration_ms, success=False)
        raise

async def execute_with_retry(query_obj: dict, operation_name: str, max_retries: int = 2) -> Any:
    last_error = None
    total_start = time.time()

    for attempt in range(1, max_retries + 1):
        try:
            result = await execute_query(query_obj, f"{operation_name}_attempt{attempt}")
            if attempt > 1:
                total_duration_ms = (time.time() - total_start) * 1000
                logger.info(f"DB_{operation_name.upper()}_RECOVERED: total_duration_ms={total_duration_ms:.2f} attempts={attempt}")
            return result
        except Exception as e:
            last_error = e
            if attempt < max_retries:
                wait_time = 2 ** (attempt - 1)
                logger.warning(f"DB_{operation_name.upper()}_RETRY: attempt={attempt}/{max_retries} error={str(e)} retry_in={wait_time}s")
                await asyncio.sleep(wait_time)
            else:
                total_duration_ms = (time.time() - total_start) * 1000
                logger.error(f"DB_{operation_name.upper()}_FAILED: total_duration_ms={total_duration_ms:.2f} attempts={max_retries} error={str(last_error)}")
                raise last_error

+            elif isinstance(arg, dict) and "type" in arg:


Now rebuild and deploy.


Bash

docker build -t asia-south1-docker.pkg.dev/mayaresearchgcp/maya-pipecat/maya-pipecat-v5:latest -f deployments/docker/Dockerfile . 2>&1 | tail -5 && docker push asia-south1-docker.pkg.dev/mayaresearchgcp/maya-pipecat/maya-pipecat-v5:latest 2>&1 | tail -3

Build and push with fix


#19 exporting to image
#19 exporting layers 0.1s done
#19 writing image sha256:755e7da90d6e4a47dfb400b8cfcaf52f366d56d127f2be3c488fe8ee2d7fe253 done
#19 naming to asia-south1-docker.pkg.dev/mayaresearchgcp/maya-pipecat/maya-pipecat-v5:latest done
#19 DONE 0.1s
6143ee9e3de0: Layer already exists
54130025cb12: Pushed
latest: digest: sha256:7e25806ec7c1d619db13efc5a19a1f0dea437e09003a2dfab10e5faa9fe86a34 size: 3670


kubectl rollout restart deployment/maya-pipecat-v5 && kubectl rollout status deployment/maya-pipecat-v5 --timeout=600s 2>&1 | tail -3

Restart v5 rollout


deployment.apps/maya-pipecat-v5 restarted
Waiting for deployment "maya-pipecat-v5" rollout to finish: 1 old replicas are pending termination...
Waiting for deployment "maya-pipecat-v5" rollout to finish: 1 old replicas are pending termination...
deployment "maya-pipecat-v5" successfully rolled out


Deployed. Now let me verify the fix is working.


POD=$(kubectl get pods -l app=maya-pipecat-v5 --field-selector=status.phase=Running -o jsonpath='{.items[0].metadata.name}') && kubectl exec $POD -- python3 -c "
import asyncio
from src.repository.config_v2_repository import config_v2_repo

async def do():
    config = await config_v2_repo.get_firered_config()
    print('Config:', config)

asyncio.run(do())
" 2>&1

Verify firered config loads correctly


2026-04-04 10:58:53.711 | DEBUG    | src.core.database:get_turso_client:217 - Turso client initialized: libsql://mayaapp-maya.aws-ap-south-1.turso.io
2026-04-04 10:58:53.790 | DEBUG    | src.core.database:_log_db_timing:50 - DB_CONFIG_V2_FIRERED: duration_ms=78.61 rows=0 success=True
Config: {'enabled': False, 'percentage': 0, 'max_queue': 3}


Still 0 rows. The fix didn't help — because the repo query uses the Turso format `[{"type": "text", "value": "firered_config"}]` as args, and now with the fix it passes them as-is, which is correct. But still returning 0 rows. Let me check the actual HTTP request being sent.


POD=$(kubectl get pods -l app=maya-pipecat-v5 --field-selector=status.phase=Running -o jsonpath='{.items[0].metadata.name}') && kubectl exec $POD -- python3 -c "
import asyncio
from src.core.database import execute_query

async def do():
    # Test parameterized with dict args (Turso format)
    r1 = await execute_query({
        'sql': 'SELECT config_value FROM configurations_v2 WHERE config_key = ?',
        'args': [{'type': 'text', 'value': 'firered_config'}]
    }, 'test1')
    print('Dict args rows:', len(r1.rows) if r1 else 0)

    # Test inline
    r2 = await execute_query({
        'sql': \"SELECT config_value FROM configurations_v2 WHERE config_key = 'firered_config'\",
        'args': []
    }, 'test2')
    print('Inline rows:', len(r2.rows) if r2 else 0)
    if r2 and r2.rows:
        print('Value:', r2.rows[0])

asyncio.run(do())
" 2>&1

Test both query styles


Error: Exit code 1
error: cannot exec into a container in a completed pod; current phase is Succeeded


POD=$(kubectl get pods -l app=maya-pipecat-v5 --field-selector=status.phase=Running -o jsonpath='{.items[0].metadata.name}' 2>/dev/null) && echo "Pod: $POD" && kubectl exec $POD -- python3 -c "
import asyncio
from src.core.database import execute_query

async def do():
    r1 = await execute_query({
        'sql': 'SELECT config_value FROM configurations_v2 WHERE config_key = ?',
        'args': [{'type': 'text', 'value': 'firered_config'}]
    }, 'test1')
    print('Dict args rows:', len(r1.rows) if r1 else 0)

    r2 = await execute_query({
        'sql': \"SELECT config_value FROM configurations_v2 WHERE config_key = 'firered_config'\",
        'args': []
    }, 'test2')
    print('Inline rows:', len(r2.rows) if r2 else 0)

asyncio.run(do())
" 2>&1

Test query on running pod


Pod: maya-pipecat-v5-6b698755b5-25lzt
2026-04-04 10:59:15.993 | DEBUG    | src.core.database:get_turso_client:219 - Turso client initialized: libsql://mayaapp-maya.aws-ap-south-1.turso.io
2026-04-04 10:59:16.102 | DEBUG    | src.core.database:_log_db_timing:50 - DB_TEST1: duration_ms=108.73 rows=1 success=True slow=SLOW
2026-04-04 10:59:16.108 | DEBUG    | src.core.database:_log_db_timing:50 - DB_TEST2: duration_ms=5.62 rows=1 success=True
Dict args rows: 1
Inline rows: 1


Now both return 1 row. The fix is working. Let me verify the full config flow.


kubectl exec maya-pipecat-v5-6b698755b5-25lzt -- python3 -c "
import asyncio
from src.repository.config_v2_repository import config_v2_repo

async def do():
    config = await config_v2_repo.get_firered_config()
    print('Config:', config)

asyncio.run(do())
" 2>&1

Verify full config flow


2026-04-04 10:59:23.498 | DEBUG    | src.core.database:get_turso_client:219 - Turso client initialized: libsql://mayaapp-maya.aws-ap-south-1.turso.io
2026-04-04 10:59:23.574 | DEBUG    | src.core.database:_log_db_timing:50 - DB_CONFIG_V2_FIRERED: duration_ms=75.56 rows=1 success=True
Config: {'enabled': True, 'range_start': 50, 'range_end': 70, 'max_queue': 15}


Working. FireRed config is now loading correctly: `enabled=True, range_start=50, range_end=70, max_queue=15`.

Devices with hash 50-69 will now use FireRed for single-image edits. The `database.py` fix also means all other parameterized queries across the app were likely broken the same way — this fix should resolve those too.


check for logs


kubectl logs -l app=maya-pipecat-v5 --all-containers --tail=2000 --since=3m 2>&1 | grep -i "FIRERED" | head -20

Check for FIRERED logs


{"timestamp": "2026-04-04T10:59:45.349Z", "level": "INFO", "logger": "src.bot.services.image_service", "function": "edit_image_async", "line": 396, "message": "FIRERED_EDIT_ATTEMPT: edit_id=edit_1775300383 device=d171f85218885611", "service": "maya-pipecat-v5", "env": "development", "version": "1.0.0", "status": "info"}
{"timestamp": "2026-04-04T10:59:56.133Z", "level": "INFO", "logger": "src.service.firered_client", "function": "edit_image", "line": 123, "message": "FIRERED_EDIT_SUCCESS: api_latency=10.778s gpu_time=2.43s size=1413999 output_size=1024x1024", "service": "maya-pipecat-v5", "env": "development", "version": "1.0.0", "status": "info"}
{"timestamp": "2026-04-04T10:59:56.271Z", "level": "INFO", "logger": "src.bot.services.image_service", "function": "edit_image_async", "line": 424, "message": "CF_IMAGE_EDITED: edit_id=edit_1775300383 session_id=d8436a24-1c75-4c3f-a932-8010fa397286 device_id=d171f85218885611 persona=edit_photos persona_name=Edit Photos request_id=9e4f64c0 provider=firered total_latency=13.107s api_latency=10.778s s3_url=https://storage.googleapis.com/maya-session-data/d171f85218885611/d8436a24-1c75-4c3f-a932-8010fa397286/edits/edit_1775300383.png", "service": "maya-pipecat-v5", "env": "development", "version": "1.0.0", "status": "info"}
{"timestamp": "2026-04-04T10:59:26.078Z", "level": "INFO", "logger": "src.bot.services.image_service", "function": "edit_image_async", "line": 396, "message": "FIRERED_EDIT_ATTEMPT: edit_id=edit_1775300363 device=065a236b0187fe81", "service": "maya-pipecat-v5", "env": "development", "version": "1.0.0", "status": "info"}
{"timestamp": "2026-04-04T10:59:37.244Z", "level": "INFO", "logger": "src.service.firered_client", "function": "edit_image", "line": 123, "message": "FIRERED_EDIT_SUCCESS: api_latency=11.158s gpu_time=2.5s size=1743427 output_size=896x1184", "service": "maya-pipecat-v5", "env": "development", "version": "1.0.0", "status": "info"}
{"timestamp": "2026-04-04T10:59:37.396Z", "level": "INFO", "logger": "src.bot.services.image_service", "function": "edit_image_async", "line": 424, "message": "CF_IMAGE_EDITED: edit_id=edit_1775300363 session_id=6dd41430-d42f-4531-b3dd-7eec5adc5bf9 device_id=065a236b0187fe81 persona=edit_photos persona_name=Edit Photos request_id=28178967 provider=firered total_latency=13.710s api_latency=11.158s s3_url=https://storage.googleapis.com/maya-session-data/065a236b0187fe81/6dd41430-d42f-4531-b3dd-7eec5adc5bf9/edits/edit_1775300363.png", "service": "maya-pipecat-v5", "env": "development", "version": "1.0.0", "status": "info"}
{"timestamp": "2026-04-04T10:59:44.723Z", "level": "INFO", "logger": "src.bot.services.image_service", "function": "edit_image_async", "line": 396, "message": "FIRERED_EDIT_ATTEMPT: edit_id=edit_1775300379 device=100d90ba619b7556", "service": "maya-pipecat-v5", "env": "development", "version": "1.0.0", "status": "info"}
{"timestamp": "2026-04-04T10:59:52.068Z", "level": "INFO", "logger": "src.service.firered_client", "function": "edit_image", "line": 123, "message": "FIRERED_EDIT_SUCCESS: api_latency=7.339s gpu_time=2.42s size=860664 output_size=832x1248", "service": "maya-pipecat-v5", "env": "development", "version": "1.0.0", "status": "info"}
{"timestamp": "2026-04-04T10:59:52.192Z", "level": "INFO", "logger": "src.bot.services.image_service", "function": "edit_image_async", "line": 424, "message": "CF_IMAGE_EDITED: edit_id=edit_1775300379 session_id=138e0462-0ca4-4651-b875-8b605a3c6760 device_id=100d90ba619b7556 persona=edit_photos persona_name=Edit Photos request_id=0d760946 provider=firered total_latency=12.270s api_latency=7.339s s3_url=https://storage.googleapis.com/maya-session-data/100d90ba619b7556/138e0462-0ca4-4651-b875-8b605a3c6760/edits/edit_1775300379.png", "service": "maya-pipecat-v5", "env": "development", "version": "1.0.0", "status": "info"}
{"timestamp": "2026-04-04T11:00:05.334Z", "level": "INFO", "logger": "src.bot.services.image_service", "function": "edit_image_async", "line": 396, "message": "FIRERED_EDIT_ATTEMPT: edit_id=edit_1775300402 device=100d90ba619b7556", "service": "maya-pipecat-v5", "env": "development", "version": "1.0.0", "status": "info"}
{"timestamp": "2026-04-04T11:00:14.660Z", "level": "INFO", "logger": "src.service.firered_client", "function": "edit_image", "line": 123, "message": "FIRERED_EDIT_SUCCESS: api_latency=9.321s gpu_time=2.4s size=992555 output_size=832x1248", "service": "maya-pipecat-v5", "env": "development", "version": "1.0.0", "status": "info"}
{"timestamp": "2026-04-04T11:00:14.811Z", "level": "INFO", "logger": "src.bot.services.image_service", "function": "edit_image_async", "line": 424, "message": "CF_IMAGE_EDITED: edit_id=edit_1775300402 session_id=138e0462-0ca4-4651-b875-8b605a3c6760 device_id=100d90ba619b7556 persona=edit_photos persona_name=Edit Photos request_id=9d24b886 provider=firered total_latency=12.271s api_latency=9.321s s3_url=https://storage.googleapis.com/maya-session-data/100d90ba619b7556/138e0462-0ca4-4651-b875-8b605a3c6760/edits/edit_1775300402.png", "service": "maya-pipecat-v5", "env": "development", "version": "1.0.0", "status": "info"}
{"timestamp": "2026-04-04T11:00:08.063Z", "level": "INFO", "logger": "src.bot.services.image_service", "function": "edit_image_async", "line": 396, "message": "FIRERED_EDIT_ATTEMPT: edit_id=edit_1775300404 device=610716246d1d4b0d", "service": "maya-pipecat-v5", "env": "development", "version": "1.0.0", "status": "info"}
{"timestamp": "2026-04-04T11:00:21.093Z", "level": "INFO", "logger": "src.service.firered_client", "function": "edit_image", "line": 123, "message": "FIRERED_EDIT_SUCCESS: api_latency=13.021s gpu_time=2.5s size=1338845 output_size=896x1184", "service": "maya-pipecat-v5", "env": "development", "version": "1.0.0", "status": "info"}
{"timestamp": "2026-04-04T11:00:21.299Z", "level": "INFO", "logger": "src.bot.services.image_service", "function": "edit_image_async", "line": 424, "message": "CF_IMAGE_EDITED: edit_id=edit_1775300404 session_id=cd49dc01-8bf9-4f98-bffb-c8d40895e26d device_id=610716246d1d4b0d persona=edit_photos persona_name=Edit Photos request_id=b215c641 provider=firered total_latency=17.175s api_latency=13.021s s3_url=https://storage.googleapis.com/maya-session-data/610716246d1d4b0d/cd49dc01-8bf9-4f98-bffb-c8d40895e26d/edits/edit_1775300404.png", "service": "maya-pipecat-v5", "env": "development", "version": "1.0.0", "status": "info"}
{"timestamp": "2026-04-04T11:00:12.653Z", "level": "WARNING", "logger": "src.service.firered_client", "function": "check_queue", "line": 59, "message": "FIRERED_QUEUE_CHECK_FAILED: ", "service": "maya-pipecat-v5", "env": "development", "version": "1.0.0", "status": "info"}
{"timestamp": "2026-04-04T10:58:54.258Z", "level": "INFO", "logger": "src.bot.services.image_service", "function": "edit_image_async", "line": 396, "message": "FIRERED_EDIT_ATTEMPT: edit_id=edit_1775300331 device=ba70117ea882329b", "service": "maya-pipecat-v5", "env": "development", "version": "1.0.0", "status": "info"}
{"timestamp": "2026-04-04T10:59:03.873Z", "level": "INFO", "logger": "src.service.firered_client", "function": "edit_image", "line": 123, "message": "FIRERED_EDIT_SUCCESS: api_latency=9.610s gpu_time=2.43s size=1156709 output_size=672x1536", "service": "maya-pipecat-v5", "env": "development", "version": "1.0.0", "status": "info"}
{"timestamp": "2026-04-04T10:59:04.038Z", "level": "INFO", "logger": "src.bot.services.image_service", "function": "edit_image_async", "line": 424, "message": "CF_IMAGE_EDITED: edit_id=edit_1775300331 session_id=efe0a18f-1822-427c-a8df-d2a08d471490 device_id=ba70117ea882329b persona=edit_photos persona_name=Edit Photos request_id=66d44ab1 provider=firered total_latency=12.248s api_latency=9.610s s3_url=https://storage.googleapis.com/maya-session-data/ba70117ea882329b/efe0a18f-1822-427c-a8df-d2a08d471490/edits/edit_1775300331.png", "service": "maya-pipecat-v5", "env": "development", "version": "1.0.0", "status": "info"}
{"timestamp": "2026-04-04T10:59:20.704Z", "level": "INFO", "logger": "src.bot.services.image_service", "function": "edit_image_async", "line": 396, "message": "FIRERED_EDIT_ATTEMPT: edit_id=edit_1775300355 device=2530e8df127e29a7", "service": "maya-pipecat-v5", "env": "development", "version": "1.0.0", "status": "info"}


FireRed is live and working. Multiple successful edits:

- `FIRERED_EDIT_ATTEMPT` → `FIRERED_EDIT_SUCCESS` → `provider=firered`
- GPU time ~2.4s, API latency 7-13s
- Devices `d171f852`, `065a236b`, `100d90ba`, `610716246`, `ba70117e`, `2530e8df` all hitting FireRed

One `FIRERED_QUEUE_CHECK_FAILED` warning but edits are still going through. All good.


there is an issue ...tell me how we sening limit are sending based stattsu or expires_at we should check expires_at


Found 11 files
src/bot/services/image_service.py
src/service/usage_service.py
src/routes/v2.py
src/repository/plan_repository.py
src/bot/services/limit_checker.py
src/bot/services/music_service.py
src/bot/services/ppt_service.py
src/bot/services/video_service.py
src/bot/core/config.py
src/bot/core/main.py
src/bot/services/custom_tts.py


1	"""Usage limit checker — 3-layer credits: plan → topup → free."""
2
3	from loguru import logger
4
5
6	def can_generate(bot_session) -> str:
7	    """Check if user can generate content.
8
9	    Returns:
10	        "" — allowed
11	        "subscribe" — no plan, free exhausted → show plans popup
12	        "topup" — has plan but exhausted → show topup popup
13	    """
14	    if bot_session.plan_remaining > 0:
15	        return ""
16	    if bot_session.topup_remaining > 0:
17	        return ""
18	    if bot_session.free_remaining > 0:
19	        return ""
20
21	    # Blocked
22	    if bot_session.subscription_id:
23	        return "topup"
24	    return "subscribe"
25
26
27	def use_credit(bot_session):
28	    """Decrement one credit after successful generation. Plan first, then topup, then free."""
29	    if bot_session.plan_remaining > 0:
30	        bot_session.plan_remaining -= 1
31	        bot_session.credits_used_plan += 1
32	        logger.debug(f"CREDIT_USED_PLAN: device={bot_session.device_id} plan_remaining={bot_session.plan_remaining}")
33	    elif bot_session.topup_remaining > 0:
34	        bot_session.topup_remaining -= 1
35	        bot_session.credits_used_topup += 1
36	        logger.debug(f"CREDIT_USED_TOPUP: device={bot_session.device_id} topup_remaining={bot_session.topup_remaining}")
37	    elif bot_session.free_remaining > 0:
38	        bot_session.free_remaining -= 1
39	        logger.debug(f"CREDIT_USED_FREE: device={bot_session.device_id} free_remaining={bot_session.free_remaining}")
40
41
42	async def send_limit_popup(bot_session, rtvi_processor, block_reason: str):
43	    """Send limit reached popup with plan/topup data to the frontend.
44
45	    Fetches plan data from DB so the pricing screen shows actual prices.
46	    """
47	    from src.service.usage_service import get_plans_for_device, get_topup_for_device, get_topups_for_content
48
49	    content_type = bot_session.content_type or "image_gen"
50	    device_id = bot_session.device_id
51
52	    # Determine payment provider from region
53	    from src.service.usage_service import _get_device_region, _get_payment_provider
54	    region = await _get_device_region(device_id)
55	    payment_provider = _get_payment_provider(region)
56
57	    try:
58	        if block_reason == "topup":
59	            plan_data = await get_topup_for_device(device_id, content_type, payment_provider)
60	            topups_list = await get_topups_for_content(content_type, payment_provider)
61	        else:
62	            plan_data = await get_plans_for_device(device_id, content_type, payment_provider)
63	            topups_list = []
64
65	        popup_type = "show_plans_popup" if block_reason == "subscribe" else "show_topup_popup"
66	        await rtvi_processor.send_server_message({
67	            "type": popup_type,
68	            "data": {
69	                "content_type": content_type,
70	                "reason": block_reason,
71	                "plan": plan_data,
72	                "topups": topups_list,
73	            },
74	        })
75	        logger.info(f"LIMIT_POPUP_SENT: device={device_id} reason={block_reason} content={content_type} provider={payment_provider} plan_id={plan_data.get('plan_id') or plan_data.get('topup_id', 'none')}")
76	    except Exception as e:
77	        logger.error(f"LIMIT_POPUP_FAILED: device={device_id} error={e}")
78	        # Fallback: send without data (old behavior)
79	        popup_type = "show_plans_popup" if block_reason == "subscribe" else "show_topup_popup"
80	        await rtvi_processor.send_server_message({"type": popup_type, "content_type": content_type})
81

"""Usage limit checker — 3-layer credits: plan → topup → free."""

from loguru import logger

def can_generate(bot_session) -> str:
    """Check if user can generate content.

    Returns:
        "" — allowed
        "subscribe" — no plan, free exhausted → show plans popup
        "topup" — has plan but exhausted → show topup popup
    """
    if bot_session.plan_remaining > 0:
        return ""
    if bot_session.topup_remaining > 0:
        return ""
    if bot_session.free_remaining > 0:
        return ""

    # Blocked
    if bot_session.subscription_id:
        return "topup"
    return "subscribe"

def use_credit(bot_session):
    """Decrement one credit after successful generation. Plan first, then topup, then free."""
    if bot_session.plan_remaining > 0:
        bot_session.plan_remaining -= 1
        bot_session.credits_used_plan += 1
        logger.debug(f"CREDIT_USED_PLAN: device={bot_session.device_id} plan_remaining={bot_session.plan_remaining}")
    elif bot_session.topup_remaining > 0:
        bot_session.topup_remaining -= 1
        bot_session.credits_used_topup += 1
        logger.debug(f"CREDIT_USED_TOPUP: device={bot_session.device_id} topup_remaining={bot_session.topup_remaining}")
    elif bot_session.free_remaining > 0:
        bot_session.free_remaining -= 1
        logger.debug(f"CREDIT_USED_FREE: device={bot_session.device_id} free_remaining={bot_session.free_remaining}")

async def send_limit_popup(bot_session, rtvi_processor, block_reason: str):
    """Send limit reached popup with plan/topup data to the frontend.

    Fetches plan data from DB so the pricing screen shows actual prices.
    """
    from src.service.usage_service import get_plans_for_device, get_topup_for_device, get_topups_for_content

    content_type = bot_session.content_type or "image_gen"
    device_id = bot_session.device_id

    # Determine payment provider from region
    from src.service.usage_service import _get_device_region, _get_payment_provider
    region = await _get_device_region(device_id)
    payment_provider = _get_payment_provider(region)

    try:
        if block_reason == "topup":
            plan_data = await get_topup_for_device(device_id, content_type, payment_provider)
            topups_list = await get_topups_for_content(content_type, payment_provider)
        else:
            plan_data = await get_plans_for_device(device_id, content_type, payment_provider)
            topups_list = []

        popup_type = "show_plans_popup" if block_reason == "subscribe" else "show_topup_popup"
        await rtvi_processor.send_server_message({
            "type": popup_type,
            "data": {
                "content_type": content_type,
                "reason": block_reason,
                "plan": plan_data,
                "topups": topups_list,
            },
        })
        logger.info(f"LIMIT_POPUP_SENT: device={device_id} reason={block_reason} content={content_type} provider={payment_provider} plan_id={plan_data.get('plan_id') or plan_data.get('topup_id', 'none')}")
    except Exception as e:
        logger.error(f"LIMIT_POPUP_FAILED: device={device_id} error={e}")
        # Fallback: send without data (old behavior)
        popup_type = "show_plans_popup" if block_reason == "subscribe" else "show_topup_popup"
        await rtvi_processor.send_server_message({"type": popup_type, "content_type": content_type})


The limit check is based on `bot_session.plan_remaining`, `topup_remaining`, `free_remaining`. Let me check where these are set — that's where the subscription status vs expires_at check happens.


180:        self.plan_remaining: int = 0
182:        self.subscription_id: Optional[str] = None


No matches found


src/bot/core/main.py:90:        bot_session.plan_remaining = usage["plan_remaining"]
src/service/usage_service.py:57:        "plan_remaining": plan_remaining,


1	"""Service layer for usage state, generation checks, and plan assignment."""
2
3	import hashlib
4	from typing import Dict, Any, List
5
6	from loguru import logger
7
8	from src.repository.plan_repository import plan_repo
9	from src.repository.subscription_repository import subscription_repo
10	from src.repository.topup_repository import topup_repo
11	from src.repository.usage_repository import usage_repo
12
13
14	def _device_hash(device_id: str) -> int:
15	    """Deterministic 0-99 hash for A/B assignment."""
16	    return int(hashlib.sha256(device_id.encode()).hexdigest(), 16) % 100
17
18
19	async def _get_device_info(device_id: str) -> Dict[str, Any]:
20	    """Get device region and country, defaulting to india for legacy devices."""
21	    from src.repository.device_repository import device_repo
22	    device = await device_repo.get_device(device_id)
23	    if device:
24	        return {
25	            "region": device.get("region") or "india",
26	            "country": device.get("country"),
27	        }
28	    return {"region": "india", "country": None}
29
30
31	async def _get_device_region(device_id: str) -> str:
32	    """Get device region, defaulting to 'india' for legacy devices."""
33	    info = await _get_device_info(device_id)
34	    return info["region"]
35
36
37	def _get_payment_provider(region: str) -> str:
38	    """Map region to payment provider."""
39	    return "google_play" if region == "global" else "razorpay"
40
41
42	async def get_usage_state(device_id: str, content_type: str, payment_provider: str = None) -> Dict[str, Any]:
43	    """Build usage state for a device + content type (plan/topup level).
44
45	    Free limits are persona-level now, handled separately in can_generate_persona().
46	    Subscription and topup repos already handle 'all' fallback internally.
47	    If payment_provider is given, only checks subs from that provider.
48	    """
49	    sub = await subscription_repo.get_active(device_id, content_type, payment_provider)
50	    plan_remaining = sub["credits_remaining"] if sub else 0
51	    subscription_id = sub["id"] if sub else None
52	    plan_content_type = sub["plan_content_type"] if sub else None
53
54	    topup_remaining = await topup_repo.get_credits(device_id, content_type, payment_provider)
55
56	    return {
57	        "plan_remaining": plan_remaining,
58	        "topup_remaining": topup_remaining,
59	        "has_plan": sub is not None,
60	        "subscription_id": subscription_id,
61	        "plan_content_type": plan_content_type,
62	    }
63
64
65	async def can_generate_persona(device_id: str, persona_id: str, payment_provider: str = None) -> Dict[str, Any]:
66	    """Check if the device can use a persona.
67
68	    Credit check order: plan → topup → region free limit (A/B) → persona free limit.
69
70	    For global users, region_free_limits with ab_percentage controls what fraction
71	    of users get free access. Users outside the A/B percentage see the paywall.
72
73	    Args:
74	        device_id: The device identifier.
75	        persona_id: The persona to check.
76	        payment_provider: If set, filters plans/topups shown when blocked.
77
78	    Returns:
79	        {"allowed": True} — allowed
80	        {"allowed": False, "reason": "subscribe"|"topup", ...} — blocked
81	    """
82	    from src.repository.persona_repository import persona_repo
83
84	    persona = await persona_repo.get_persona_by_id(persona_id)
85	    if not persona:
86	        return {"allowed": True}
87
88	    content_type = persona.get("content_type", "")
89
90	    # Resolve region, country, and payment provider if not provided
91	    device_info = await _get_device_info(device_id)
92	    region = device_info["region"]
93	    country = device_info["country"]
94	    if not payment_provider:
95	        payment_provider = _get_payment_provider(region)
96
97	    # 1. Check plan credits (content_type level, with 'all' fallback in repo)
98	    sub = await subscription_repo.get_active(device_id, content_type, payment_provider)
99	    plan_remaining = sub["credits_remaining"] if sub else 0
100	    if plan_remaining > 0:
101	        return {"allowed": True}
102
103	    # 2. Check topup credits (content_type level, with 'all' fallback in repo)
104	    topup_remaining = await topup_repo.get_credits(device_id, content_type, payment_provider)
105	    if topup_remaining > 0:
106	        return {"allowed": True}
107
108	    # 3. Free limits: India uses persona-level (preserves old behavior),
109	    #    Global uses region_free_limits with A/B testing.
110	    if region == "india":
111	        # India: always use persona-level free limits
112	        free_count = persona.get("free_count", 0)
113	        free_window_hours = persona.get("free_window_hours", 24)
114	        if free_count > 0:
115	            used = await usage_repo.count_persona_generations(device_id, persona_id, content_type, free_window_hours)
116	            if used < free_count:
117	                return {"allowed": True}
118	    else:
119	        # Global: use region_free_limits with A/B percentage
120	        # Group A (hash < ab_percentage): fully free, no limits, no paywall
121	        # Group B (hash >= ab_percentage): gets free_count free, then paywall
122	        region_limits = await plan_repo.get_region_free_limits(region)
123	        region_limit = region_limits.get(content_type)
124	        if region_limit:
125	            ab_pct = region_limit.get("ab_percentage", 100)
126	            device_h = _device_hash(device_id)
127
128	            if device_h < ab_pct:
129	                # Group A: fully free — always allowed
130	                return {"allowed": True}
131	            else:
132	                # Group B: gets free_count free, then paywall
133	                free_count = region_limit["free_count"]
134	                free_window = region_limit["free_window_hours"]
135	                if free_count > 0:
136	                    used = await usage_repo.count_persona_generations(device_id, persona_id, content_type, free_window)
137	                    if used < free_count:
138	                        return {"allowed": True}
139	        else:
140	            # No region limits configured — fall back to persona-level
141	            free_count = persona.get("free_count", 0)
142	            free_window_hours = persona.get("free_window_hours", 24)
143	            if free_count > 0:
144	                used = await usage_repo.count_persona_generations(device_id, persona_id, content_type, free_window_hours)
145	                if used < free_count:
146	                    return {"allowed": True}
147
148	    # Blocked — show payment options filtered by provider
149	    # Apply country-level price overrides for global users
150	    country_prices = {}
151	    if country and region != "india":
152	        from src.repository.country_prices_repository import country_prices_repo
153	        country_prices = await country_prices_repo.get_prices_for_country(country)
154
155	    if sub:
156	        topup = await get_topup_for_device(device_id, content_type, payment_provider)
157	        if country_prices and topup:
158	            from src.repository.country_prices_repository import country_prices_repo
159	            topup = country_prices_repo.apply_country_price(topup, country_prices)
160	        return {
161	            "allowed": False,
162	            "reason": "topup",
163	            "content_type": content_type,
164	            "persona_id": persona_id,
165	            "plan": topup,
166	        }
167	    else:
168	        plan = await get_plans_for_device(device_id, content_type, payment_provider)
169	        if country_prices and plan:
170	            from src.repository.country_prices_repository import country_prices_repo
171	            plan = country_prices_repo.apply_country_price(plan, country_prices)
172	        return {
173	            "allowed": False,
174	            "reason": "subscribe",
175	            "content_type": content_type,
176	            "persona_id": persona_id,
177	            "plan": plan,
178	        }
179
180
181	def _assign_plan(device_id: str, plans: List[Dict[str, Any]]) -> Dict[str, Any]:
182	    """Deterministically assign a plan variant to a device using weighted hash."""
183	    if not plans:
184	        return {}
185	    h = _device_hash(device_id)
186	    cumulative = 0
187	    for p in plans:
188	        cumulative += p.get("percentage", 0)
189	        if h < cumulative:
190	            plan = dict(p)
191	            plan.pop("percentage", None)
192	            return plan
193	    plan = dict(plans[-1])
194	    plan.pop("percentage", None)
195	    return plan
196
197
198	async def get_plans_for_device(device_id: str, content_type: str, payment_provider: str = None) -> Dict[str, Any]:
199	    """Get the A/B-assigned plan for a device + content type.
200
201	    Tries specific content_type first, falls back to 'all' if no plans found.
202	    """
203	    plans = await plan_repo.get_plans_for_content(content_type, payment_provider)
204	    if not plans and content_type != "all":
205	        plans = await plan_repo.get_plans_for_content("all", payment_provider)
206	    return _assign_plan(device_id, plans)
207
208
209	async def get_topup_for_device(device_id: str, content_type: str, payment_provider: str = None) -> Dict[str, Any]:
210	    """Get the A/B-assigned topup for a device + content type.
211
212	    Tries specific content_type first, falls back to 'all' if no topups found.
213	    """
214	    topups = await plan_repo.get_topups_for_content(content_type, payment_provider)
215	    if not topups and content_type != "all":
216	        topups = await plan_repo.get_topups_for_content("all", payment_provider)
217	    return _assign_plan(device_id, topups)
218
219
220	async def get_topups_for_content(content_type: str, payment_provider: str = None) -> List[Dict[str, Any]]:
221	    """Get available topup packs for a content type."""
222	    return await plan_repo.get_topups_for_content(content_type, payment_provider)
223
224
225	async def get_all_usage(device_id: str) -> Dict[str, Dict[str, Any]]:
226	    """Get usage state for all content types."""
227	    content_types = ["image_gen", "video_gen", "ad_video", "ppt_gen", "music_gen"]
228	    result = {}
229	    for ct in content_types:
230	        result[ct] = await get_usage_state(device_id, ct)
231	    return result
232

"""Service layer for usage state, generation checks, and plan assignment."""

import hashlib
from typing import Dict, Any, List

from loguru import logger

from src.repository.plan_repository import plan_repo
from src.repository.subscription_repository import subscription_repo
from src.repository.topup_repository import topup_repo
from src.repository.usage_repository import usage_repo

def _device_hash(device_id: str) -> int:
    """Deterministic 0-99 hash for A/B assignment."""
    return int(hashlib.sha256(device_id.encode()).hexdigest(), 16) % 100

async def _get_device_info(device_id: str) -> Dict[str, Any]:
    """Get device region and country, defaulting to india for legacy devices."""
    from src.repository.device_repository import device_repo
    device = await device_repo.get_device(device_id)
    if device:
        return {
            "region": device.get("region") or "india",
            "country": device.get("country"),
        }
    return {"region": "india", "country": None}

async def _get_device_region(device_id: str) -> str:
    """Get device region, defaulting to 'india' for legacy devices."""
    info = await _get_device_info(device_id)
    return info["region"]

def _get_payment_provider(region: str) -> str:
    """Map region to payment provider."""
    return "google_play" if region == "global" else "razorpay"

async def get_usage_state(device_id: str, content_type: str, payment_provider: str = None) -> Dict[str, Any]:
    """Build usage state for a device + content type (plan/topup level).

    Free limits are persona-level now, handled separately in can_generate_persona().
    Subscription and topup repos already handle 'all' fallback internally.
    If payment_provider is given, only checks subs from that provider.
    """
    sub = await subscription_repo.get_active(device_id, content_type, payment_provider)
    plan_remaining = sub["credits_remaining"] if sub else 0
    subscription_id = sub["id"] if sub else None
    plan_content_type = sub["plan_content_type"] if sub else None

    topup_remaining = await topup_repo.get_credits(device_id, content_type, payment_provider)

    return {
        "plan_remaining": plan_remaining,
        "topup_remaining": topup_remaining,
        "has_plan": sub is not None,
        "subscription_id": subscription_id,
        "plan_content_type": plan_content_type,
    }

async def can_generate_persona(device_id: str, persona_id: str, payment_provider: str = None) -> Dict[str, Any]:
    """Check if the device can use a persona.

    Credit check order: plan → topup → region free limit (A/B) → persona free limit.

    For global users, region_free_limits with ab_percentage controls what fraction
    of users get free access. Users outside the A/B percentage see the paywall.

    Args:
        device_id: The device identifier.
        persona_id: The persona to check.
        payment_provider: If set, filters plans/topups shown when blocked.

    Returns:
        {"allowed": True} — allowed
        {"allowed": False, "reason": "subscribe"|"topup", ...} — blocked
    """
    from src.repository.persona_repository import persona_repo

    persona = await persona_repo.get_persona_by_id(persona_id)
    if not persona:
        return {"allowed": True}

    content_type = persona.get("content_type", "")

    # Resolve region, country, and payment provider if not provided
    device_info = await _get_device_info(device_id)
    region = device_info["region"]
    country = device_info["country"]
    if not payment_provider:
        payment_provider = _get_payment_provider(region)

    # 1. Check plan credits (content_type level, with 'all' fallback in repo)
    sub = await subscription_repo.get_active(device_id, content_type, payment_provider)
    plan_remaining = sub["credits_remaining"] if sub else 0
    if plan_remaining > 0:
        return {"allowed": True}

    # 2. Check topup credits (content_type level, with 'all' fallback in repo)
    topup_remaining = await topup_repo.get_credits(device_id, content_type, payment_provider)
    if topup_remaining > 0:
        return {"allowed": True}

    # 3. Free limits: India uses persona-level (preserves old behavior),
    #    Global uses region_free_limits with A/B testing.
    if region == "india":
        # India: always use persona-level free limits
        free_count = persona.get("free_count", 0)
        free_window_hours = persona.get("free_window_hours", 24)
        if free_count > 0:
            used = await usage_repo.count_persona_generations(device_id, persona_id, content_type, free_window_hours)
            if used < free_count:
                return {"allowed": True}
    else:
        # Global: use region_free_limits with A/B percentage
        # Group A (hash < ab_percentage): fully free, no limits, no paywall
        # Group B (hash >= ab_percentage): gets free_count free, then paywall
        region_limits = await plan_repo.get_region_free_limits(region)
        region_limit = region_limits.get(content_type)
        if region_limit:
            ab_pct = region_limit.get("ab_percentage", 100)
            device_h = _device_hash(device_id)

            if device_h < ab_pct:
                # Group A: fully free — always allowed
                return {"allowed": True}
            else:
                # Group B: gets free_count free, then paywall
                free_count = region_limit["free_count"]
                free_window = region_limit["free_window_hours"]
                if free_count > 0:
                    used = await usage_repo.count_persona_generations(device_id, persona_id, content_type, free_window)
                    if used < free_count:
                        return {"allowed": True}
        else:
            # No region limits configured — fall back to persona-level
            free_count = persona.get("free_count", 0)
            free_window_hours = persona.get("free_window_hours", 24)
            if free_count > 0:
                used = await usage_repo.count_persona_generations(device_id, persona_id, content_type, free_window_hours)
                if used < free_count:
                    return {"allowed": True}

    # Blocked — show payment options filtered by provider
    # Apply country-level price overrides for global users
    country_prices = {}
    if country and region != "india":
        from src.repository.country_prices_repository import country_prices_repo
        country_prices = await country_prices_repo.get_prices_for_country(country)

    if sub:
        topup = await get_topup_for_device(device_id, content_type, payment_provider)
        if country_prices and topup:
            from src.repository.country_prices_repository import country_prices_repo
            topup = country_prices_repo.apply_country_price(topup, country_prices)
        return {
            "allowed": False,
            "reason": "topup",
            "content_type": content_type,
            "persona_id": persona_id,
            "plan": topup,
        }
    else:
        plan = await get_plans_for_device(device_id, content_type, payment_provider)
        if country_prices and plan:
            from src.repository.country_prices_repository import country_prices_repo
            plan = country_prices_repo.apply_country_price(plan, country_prices)
        return {
            "allowed": False,
            "reason": "subscribe",
            "content_type": content_type,
            "persona_id": persona_id,
            "plan": plan,
        }

def _assign_plan(device_id: str, plans: List[Dict[str, Any]]) -> Dict[str, Any]:
    """Deterministically assign a plan variant to a device using weighted hash."""
    if not plans:
        return {}
    h = _device_hash(device_id)
    cumulative = 0
    for p in plans:
        cumulative += p.get("percentage", 0)
        if h < cumulative:
            plan = dict(p)
            plan.pop("percentage", None)
            return plan
    plan = dict(plans[-1])
    plan.pop("percentage", None)
    return plan

async def get_plans_for_device(device_id: str, content_type: str, payment_provider: str = None) -> Dict[str, Any]:
    """Get the A/B-assigned plan for a device + content type.

    Tries specific content_type first, falls back to 'all' if no plans found.
    """
    plans = await plan_repo.get_plans_for_content(content_type, payment_provider)
    if not plans and content_type != "all":
        plans = await plan_repo.get_plans_for_content("all", payment_provider)
    return _assign_plan(device_id, plans)

async def get_topup_for_device(device_id: str, content_type: str, payment_provider: str = None) -> Dict[str, Any]:
    """Get the A/B-assigned topup for a device + content type.

    Tries specific content_type first, falls back to 'all' if no topups found.
    """
    topups = await plan_repo.get_topups_for_content(content_type, payment_provider)
    if not topups and content_type != "all":
        topups = await plan_repo.get_topups_for_content("all", payment_provider)
    return _assign_plan(device_id, topups)

async def get_topups_for_content(content_type: str, payment_provider: str = None) -> List[Dict[str, Any]]:
    """Get available topup packs for a content type."""
    return await plan_repo.get_topups_for_content(content_type, payment_provider)

async def get_all_usage(device_id: str) -> Dict[str, Dict[str, Any]]:
    """Get usage state for all content types."""
    content_types = ["image_gen", "video_gen", "ad_video", "ppt_gen", "music_gen"]
    result = {}
    for ct in content_types:
        result[ct] = await get_usage_state(device_id, ct)
    return result


Now let me check `subscription_repo.get_active` — that's where the status vs expires_at check matters.


1	"""Repository for user subscriptions (plan purchases)."""
2
3	import time
4	import uuid
5	from datetime import datetime, timezone
6	from typing import Optional, Dict, Any
7
8	from loguru import logger
9
10	from src.repository.base import BaseRepository
11
12
13	class SubscriptionRepository(BaseRepository):
14	    """Handles subscription CRUD and credit tracking."""
15
16	    async def _get_active_for_content(self, device_id: str, content_type: str, payment_provider: str = None) -> Optional[Dict[str, Any]]:
17	        """Get the active (non-expired) subscription for a device + specific content type.
18
19	        If payment_provider is given, only returns subs from that provider
20	        (india=razorpay, global=google_play).
21	        """
22	        client = self.get_client()
23	        if not client:
24	            return None
25
26	        now = int(datetime.now(timezone.utc).timestamp())
27	        start_time = time.time()
28	        try:
29	            if payment_provider:
30	                result = await self.query({
31	                    "sql": """
32	                        SELECT s.id, s.plan_id, s.credits_total, s.credits_used,
33	                               s.expires_at, s.status, s.started_at, p.content_type
34	                        FROM subscriptions s
35	                        JOIN persona_plans p ON s.plan_id = p.plan_id
36	                        WHERE s.device_id = ? AND p.content_type = ?
37	                          AND s.status = 'active' AND s.expires_at > ?
38	                          AND s.payment_provider = ?
39	                        ORDER BY s.expires_at DESC
40	                        LIMIT 1
41	                    """,
42	                    "args": [device_id, content_type, now, payment_provider]
43	                }, "get_active_subscription")
44	            else:
45	                result = await self.query({
46	                    "sql": """
47	                        SELECT s.id, s.plan_id, s.credits_total, s.credits_used,
48	                               s.expires_at, s.status, s.started_at, p.content_type
49	                        FROM subscriptions s
50	                        JOIN persona_plans p ON s.plan_id = p.plan_id
51	                        WHERE s.device_id = ? AND p.content_type = ?
52	                          AND s.status = 'active' AND s.expires_at > ?
53	                        ORDER BY s.expires_at DESC
54	                        LIMIT 1
55	                    """,
56	                    "args": [device_id, content_type, now]
57	                }, "get_active_subscription")
58
59	            if not result.rows:
60	                return None
61
62	            row = result.rows[0]
63	            credits_total = self.extract_int(row, 2, 0)
64	            credits_used = self.extract_int(row, 3, 0)
65
66	            sub = {
67	                "id": self.extract_text(row, 0),
68	                "plan_id": self.extract_text(row, 1),
69	                "credits_total": credits_total,
70	                "credits_used": credits_used,
71	                "credits_remaining": max(0, credits_total - credits_used),
72	                "expires_at": self.extract_int(row, 4, 0),
73	                "status": self.extract_text(row, 5),
74	                "started_at": self.extract_int(row, 6, 0),
75	                "plan_content_type": self.extract_text(row, 7),
76	            }
77
78	            duration_ms = (time.time() - start_time) * 1000
79	            logger.debug(f"DB_SUB_ACTIVE: device={device_id} content={content_type} plan={sub['plan_id']} remaining={sub['credits_remaining']} duration_ms={duration_ms:.2f}")
80	            return sub
81	        except Exception as e:
82	            duration_ms = (time.time() - start_time) * 1000
83	            logger.error(f"DB_SUB_ACTIVE_FAILED: device={device_id} content={content_type} duration_ms={duration_ms:.2f} error={e}")
84	            return None
85
86	    async def get_active(self, device_id: str, content_type: str, payment_provider: str = None) -> Optional[Dict[str, Any]]:
87	        """Get active subscription: tries specific content_type first, then falls back to 'all'.
88
89	        This supports both India (per-content-type plans) and Global (unified 'all' plans).
90	        If payment_provider is given, only returns subs from that provider.
91	        Results are cached for 120s and invalidated on payment.
92	        """
93	        from src.core.cache_layer import cached, CacheKeys
94
95	        async def _fetch():
96	            sub = await self._get_active_for_content(device_id, content_type, payment_provider)
97	            if sub:
98	                return sub
99	            if content_type != "all":
100	                return await self._get_active_for_content(device_id, "all", payment_provider)
101	            return None
102
103	        return await cached(
104	            key=CacheKeys.active_subscription(device_id, content_type),
105	            fallback=_fetch,
106	            ttl=120,
107	        )
108
109	    async def create(
110	        self,
111	        device_id: str,
112	        plan_id: str,
113	        razorpay_payment_id: str = None,
114	        credits: int = 0,
115	        expires_at: int = 0,
116	        razorpay_subscription_id: str = None,
117	        payment_provider: str = "razorpay",
118	        google_play_token: str = None,
119	    ) -> Optional[str]:
120	        """Create a new subscription. Returns subscription id.
121
122	        For Google Play, if the same purchase_token already exists, updates the
123	        existing subscription (status, credits, expiry) instead of inserting a duplicate.
124	        """
125	        client = self.get_client()
126	        if not client:
127	            return None
128
129	        sub_id = f"sub_{uuid.uuid4().hex[:16]}"
130	        start_time = time.time()
131	        try:
132	            # Razorpay idempotency: don't create duplicate if same payment already recorded
133	            if razorpay_payment_id and not google_play_token:
134	                existing = await self.query({
135	                    "sql": "SELECT id FROM subscriptions WHERE razorpay_payment_id = ? LIMIT 1",
136	                    "args": [razorpay_payment_id]
137	                }, "check_existing_razorpay_sub")
138	                if existing.rows:
139	                    existing_id = self.extract_text(existing.rows[0], 0)
140	                    logger.info(f"DB_SUB_DUPLICATE_RAZORPAY: payment={razorpay_payment_id} existing={existing_id}")
141	                    return existing_id
142
143	            # Google Play: upsert on google_play_token + plan_id to handle re-verification
144	            if google_play_token:
145	                existing = await self.query({
146	                    "sql": "SELECT id FROM subscriptions WHERE google_play_token = ? AND plan_id = ? LIMIT 1",
147	                    "args": [google_play_token, plan_id]
148	                }, "check_existing_google_play_sub")
149
150	                if existing.rows:
151	                    existing_id = self.extract_text(existing.rows[0], 0)
152	                    await self.query({
153	                        "sql": """
154	                            UPDATE subscriptions
155	                            SET status = 'active', credits_total = ?, credits_used = 0,
156	                                expires_at = ?, razorpay_payment_id = ?
157	                            WHERE id = ?
158	                        """,
159	                        "args": [credits, expires_at, razorpay_payment_id, existing_id]
160	                    }, "update_existing_google_play_sub")
161
162	                    duration_ms = (time.time() - start_time) * 1000
163	                    logger.info(f"DB_SUB_UPDATED_GOOGLE_PLAY: id={existing_id} device={device_id} plan={plan_id} credits={credits} expires_at={expires_at} duration_ms={duration_ms:.2f}")
164	                    return existing_id
165
166	            insert_result = await self.query({
167	                "sql": """
168	                    INSERT INTO subscriptions
169	                    (id, device_id, plan_id, razorpay_payment_id, razorpay_subscription_id,
170	                     status, credits_total, credits_used, expires_at,
171	                     payment_provider, google_play_token)
172	                    VALUES (?, ?, ?, ?, ?, 'active', ?, 0, ?, ?, ?)
173	                """,
174	                "args": [sub_id, device_id, plan_id, razorpay_payment_id,
175	                         razorpay_subscription_id, credits, expires_at,
176	                         payment_provider, google_play_token]
177	            }, "create_subscription")
178
179	            # Verify the insert actually persisted
180	            verify = await self.query({
181	                "sql": "SELECT id FROM subscriptions WHERE id = ?",
182	                "args": [sub_id]
183	            }, "verify_subscription_insert")
184
185	            rows_affected = getattr(insert_result, 'rows_affected', 'unknown')
186
187	            if not verify.rows:
188	                logger.error(f"DB_SUB_INSERT_LOST: id={sub_id} device={device_id} plan={plan_id} rows_affected={rows_affected} — INSERT succeeded but row not found!")
189	                return None
190
191	            duration_ms = (time.time() - start_time) * 1000
192	            logger.info(f"DB_SUB_CREATED: id={sub_id} device={device_id} plan={plan_id} credits={credits} provider={payment_provider} expires_at={expires_at} rows_affected={rows_affected} verified=true duration_ms={duration_ms:.2f}")
193	            return sub_id
194	        except Exception as e:
195	            logger.error(f"DB_SUB_CREATE_FAILED: device={device_id} plan={plan_id} error={e}")
196	            return None
197
198	    async def find_by_razorpay_payment_id(self, razorpay_payment_id: str) -> Optional[str]:
199	        """Check if a subscription already exists for this payment (idempotency)."""
200	        client = self.get_client()
201	        if not client:
202	            return None
203	        try:
204	            result = await self.query({
205	                "sql": "SELECT id FROM subscriptions WHERE razorpay_payment_id = ? LIMIT 1",
206	                "args": [razorpay_payment_id]
207	            }, "find_sub_by_razorpay_payment")
208	            if result.rows:
209	                return self.extract_text(result.rows[0], 0)
210	            return None
211	        except Exception:
212	            return None
213
214	    async def find_by_razorpay_subscription_id(self, razorpay_subscription_id: str) -> Optional[str]:
215	        """Check if a subscription already exists for this Razorpay subscription (idempotency)."""
216	        client = self.get_client()
217	        if not client:
218	            return None
219	        try:
220	            result = await self.query({
221	                "sql": "SELECT id FROM subscriptions WHERE razorpay_subscription_id = ? LIMIT 1",
222	                "args": [razorpay_subscription_id]
223	            }, "find_sub_by_razorpay_sub_id")
224	            if result.rows:
225	                return self.extract_text(result.rows[0], 0)
226	            return None
227	        except Exception:
228	            return None
229
230	    async def add_usage(self, subscription_id: str, amount: int) -> bool:
231	        """Increment credits_used for a subscription. Called at session end."""
232	        client = self.get_client()
233	        if not client:
234	            return False
235
236	        try:
237	            await self.query({
238	                "sql": "UPDATE subscriptions SET credits_used = credits_used + ? WHERE id = ?",
239	                "args": [amount, subscription_id]
240	            }, "add_subscription_usage")
241
242	            logger.info(f"DB_SUB_USAGE: id={subscription_id} added={amount}")
243	            return True
244	        except Exception as e:
245	            logger.error(f"DB_SUB_USAGE_FAILED: id={subscription_id} error={e}")
246	            return False
247
248	    async def update_status_by_google_play_token(self, purchase_token: str, status: str, expires_at: int = None, reset_credits: int = None) -> bool:
249	        """Update subscription status by Google Play purchase token (for webhooks).
250
251	        If reset_credits is provided (on renewal), also resets credits_remaining.
252	        Returns False if no matching subscription was found.
253	        """
254	        client = self.get_client()
255	        if not client:
256	            return False
257
258	        try:
259	            # Check if subscription exists first
260	            exists = await self.query({
261	                "sql": "SELECT id FROM subscriptions WHERE google_play_token = ? LIMIT 1",
262	                "args": [purchase_token]
263	            }, "check_sub_by_google_play_token")
264
265	            if not exists.rows:
266	                return False
267
268	            if reset_credits is not None and expires_at:
269	                await self.query({
270	                    "sql": "UPDATE subscriptions SET status = ?, expires_at = ?, credits_total = ?, credits_used = 0 WHERE google_play_token = ?",
271	                    "args": [status, expires_at, reset_credits, purchase_token]
272	                }, "update_sub_by_google_play_token")
273	            elif expires_at:
274	                await self.query({
275	                    "sql": "UPDATE subscriptions SET status = ?, expires_at = ? WHERE google_play_token = ?",
276	                    "args": [status, expires_at, purchase_token]
277	                }, "update_sub_by_google_play_token")
278	            else:
279	                await self.query({
280	                    "sql": "UPDATE subscriptions SET status = ? WHERE google_play_token = ?",
281	                    "args": [status, purchase_token]
282	                }, "update_sub_by_google_play_token")
283
284	            logger.info(f"DB_SUB_GOOGLE_PLAY_UPDATED: token={purchase_token[:20]}... status={status} reset_credits={reset_credits}")
285	            return True
286	        except Exception as e:
287	            logger.error(f"DB_SUB_GOOGLE_PLAY_UPDATE_FAILED: token={purchase_token[:20]}... error={e}")
288	            return False
289
290	    async def update_by_razorpay_sub_id(
291	        self, razorpay_subscription_id: str, status: str,
292	        expires_at: int = None, reset_credits: int = None
293	    ) -> bool:
294	        """Update subscription by Razorpay subscription ID (for webhooks).
295
296	        On renewal (subscription.charged): resets credits and extends expiry.
297	        On cancel/pause: just updates status.
298	        Returns False if no matching subscription was found.
299	        """
300	        client = self.get_client()
301	        if not client:
302	            return False
303
304	        try:
305	            # Check if subscription exists first
306	            exists = await self.query({
307	                "sql": "SELECT id FROM subscriptions WHERE razorpay_subscription_id = ? LIMIT 1",
308	                "args": [razorpay_subscription_id]
309	            }, "check_sub_by_razorpay_sub_id")
310
311	            if not exists.rows:
312	                return False
313
314	            if reset_credits is not None and expires_at:
315	                await self.query({
316	                    "sql": """
317	                        UPDATE subscriptions
318	                        SET status = ?, expires_at = ?, credits_total = ?, credits_used = 0
319	                        WHERE razorpay_subscription_id = ?
320	                    """,
321	                    "args": [status, expires_at, reset_credits, razorpay_subscription_id]
322	                }, "update_sub_by_razorpay_sub_id")
323	            elif expires_at:
324	                await self.query({
325	                    "sql": "UPDATE subscriptions SET status = ?, expires_at = ? WHERE razorpay_subscription_id = ?",
326	                    "args": [status, expires_at, razorpay_subscription_id]
327	                }, "update_sub_by_razorpay_sub_id")
328	            else:
329	                await self.query({
330	                    "sql": "UPDATE subscriptions SET status = ? WHERE razorpay_subscription_id = ?",
331	                    "args": [status, razorpay_subscription_id]
332	                }, "update_sub_by_razorpay_sub_id")
333
334	            logger.info(f"DB_SUB_RAZORPAY_UPDATED: sub_id={razorpay_subscription_id} status={status} reset_credits={reset_credits}")
335	            return True
336	        except Exception as e:
337	            logger.error(f"DB_SUB_RAZORPAY_UPDATE_FAILED: sub_id={razorpay_subscription_id} error={e}")
338	            return False
339
340	    async def expire_subscription(self, subscription_id: str) -> bool:
341	        """Expire a specific subscription (when replaced by a new one)."""
342	        client = self.get_client()
343	        if not client:
344	            return False
345
346	        try:
347	            await self.query({
348	                "sql": "UPDATE subscriptions SET status = 'expired' WHERE id = ?",
349	                "args": [subscription_id]
350	            }, "expire_subscription")
351	            logger.info(f"DB_SUB_EXPIRED_REPLACED: id={subscription_id}")
352	            return True
353	        except Exception as e:
354	            logger.error(f"DB_SUB_EXPIRE_REPLACE_FAILED: id={subscription_id} error={e}")
355	            return False
356
357	    async def expire_old(self) -> int:
358	        """Expire subscriptions past their expires_at. Returns count updated."""
359	        client = self.get_client()
360	        if not client:
361	            return 0
362
363	        now = int(datetime.now(timezone.utc).timestamp())
364	        try:
365	            result = await self.query({
366	                "sql": "UPDATE subscriptions SET status = 'expired' WHERE status = 'active' AND expires_at <= ?",
367	                "args": [now]
368	            }, "expire_old_subscriptions")
369	            count = result.rows_affected if hasattr(result, 'rows_affected') else 0
370	            if count:
371	                logger.info(f"DB_SUB_EXPIRED: count={count}")
372	            return count
373	        except Exception as e:
374	            logger.error(f"DB_SUB_EXPIRE_FAILED: error={e}")
375	            return 0
376
377
378	subscription_repo = SubscriptionRepository()
379

"""Repository for user subscriptions (plan purchases)."""

import time
import uuid
from datetime import datetime, timezone
from typing import Optional, Dict, Any

from loguru import logger

from src.repository.base import BaseRepository

class SubscriptionRepository(BaseRepository):
    """Handles subscription CRUD and credit tracking."""

    async def _get_active_for_content(self, device_id: str, content_type: str, payment_provider: str = None) -> Optional[Dict[str, Any]]:
        """Get the active (non-expired) subscription for a device + specific content type.

        If payment_provider is given, only returns subs from that provider
        (india=razorpay, global=google_play).
        """
        client = self.get_client()
        if not client:
            return None

        now = int(datetime.now(timezone.utc).timestamp())
        start_time = time.time()
        try:
            if payment_provider:
                result = await self.query({
                    "sql": """
                        SELECT s.id, s.plan_id, s.credits_total, s.credits_used,
                               s.expires_at, s.status, s.started_at, p.content_type
                        FROM subscriptions s
                        JOIN persona_plans p ON s.plan_id = p.plan_id
                        WHERE s.device_id = ? AND p.content_type = ?
                          AND s.status = 'active' AND s.expires_at > ?
                          AND s.payment_provider = ?
                        ORDER BY s.expires_at DESC
                        LIMIT 1
                    """,
                    "args": [device_id, content_type, now, payment_provider]
                }, "get_active_subscription")
            else:
                result = await self.query({
                    "sql": """
                        SELECT s.id, s.plan_id, s.credits_total, s.credits_used,
                               s.expires_at, s.status, s.started_at, p.content_type
                        FROM subscriptions s
                        JOIN persona_plans p ON s.plan_id = p.plan_id
                        WHERE s.device_id = ? AND p.content_type = ?
                          AND s.status = 'active' AND s.expires_at > ?
                        ORDER BY s.expires_at DESC
                        LIMIT 1
                    """,
                    "args": [device_id, content_type, now]
                }, "get_active_subscription")

            if not result.rows:
                return None

            row = result.rows[0]
            credits_total = self.extract_int(row, 2, 0)
            credits_used = self.extract_int(row, 3, 0)

            sub = {
                "id": self.extract_text(row, 0),
                "plan_id": self.extract_text(row, 1),
                "credits_total": credits_total,
                "credits_used": credits_used,
                "credits_remaining": max(0, credits_total - credits_used),
                "expires_at": self.extract_int(row, 4, 0),
                "status": self.extract_text(row, 5),
                "started_at": self.extract_int(row, 6, 0),
                "plan_content_type": self.extract_text(row, 7),
            }

            duration_ms = (time.time() - start_time) * 1000
            logger.debug(f"DB_SUB_ACTIVE: device={device_id} content={content_type} plan={sub['plan_id']} remaining={sub['credits_remaining']} duration_ms={duration_ms:.2f}")
            return sub
        except Exception as e:
            duration_ms = (time.time() - start_time) * 1000
            logger.error(f"DB_SUB_ACTIVE_FAILED: device={device_id} content={content_type} duration_ms={duration_ms:.2f} error={e}")
            return None

    async def get_active(self, device_id: str, content_type: str, payment_provider: str = None) -> Optional[Dict[str, Any]]:
        """Get active subscription: tries specific content_type first, then falls back to 'all'.

        This supports both India (per-content-type plans) and Global (unified 'all' plans).
        If payment_provider is given, only returns subs from that provider.
        Results are cached for 120s and invalidated on payment.
        """
        from src.core.cache_layer import cached, CacheKeys

        async def _fetch():
            sub = await self._get_active_for_content(device_id, content_type, payment_provider)
            if sub:
                return sub
            if content_type != "all":
                return await self._get_active_for_content(device_id, "all", payment_provider)
            return None

        return await cached(
            key=CacheKeys.active_subscription(device_id, content_type),
            fallback=_fetch,
            ttl=120,
        )

    async def create(
        self,
        device_id: str,
        plan_id: str,
        razorpay_payment_id: str = None,
        credits: int = 0,
        expires_at: int = 0,
        razorpay_subscription_id: str = None,
        payment_provider: str = "razorpay",
        google_play_token: str = None,
    ) -> Optional[str]:
        """Create a new subscription. Returns subscription id.

        For Google Play, if the same purchase_token already exists, updates the
        existing subscription (status, credits, expiry) instead of inserting a duplicate.
        """
        client = self.get_client()
        if not client:
            return None

        sub_id = f"sub_{uuid.uuid4().hex[:16]}"
        start_time = time.time()
        try:
            # Razorpay idempotency: don't create duplicate if same payment already recorded
            if razorpay_payment_id and not google_play_token:
                existing = await self.query({
                    "sql": "SELECT id FROM subscriptions WHERE razorpay_payment_id = ? LIMIT 1",
                    "args": [razorpay_payment_id]
                }, "check_existing_razorpay_sub")
                if existing.rows:
                    existing_id = self.extract_text(existing.rows[0], 0)
                    logger.info(f"DB_SUB_DUPLICATE_RAZORPAY: payment={razorpay_payment_id} existing={existing_id}")
                    return existing_id

            # Google Play: upsert on google_play_token + plan_id to handle re-verification
            if google_play_token:
                existing = await self.query({
                    "sql": "SELECT id FROM subscriptions WHERE google_play_token = ? AND plan_id = ? LIMIT 1",
                    "args": [google_play_token, plan_id]
                }, "check_existing_google_play_sub")

                if existing.rows:
                    existing_id = self.extract_text(existing.rows[0], 0)
                    await self.query({
                        "sql": """
                            UPDATE subscriptions
                            SET status = 'active', credits_total = ?, credits_used = 0,
                                expires_at = ?, razorpay_payment_id = ?
                            WHERE id = ?
                        """,
                        "args": [credits, expires_at, razorpay_payment_id, existing_id]
                    }, "update_existing_google_play_sub")

                    duration_ms = (time.time() - start_time) * 1000
                    logger.info(f"DB_SUB_UPDATED_GOOGLE_PLAY: id={existing_id} device={device_id} plan={plan_id} credits={credits} expires_at={expires_at} duration_ms={duration_ms:.2f}")
                    return existing_id

            insert_result = await self.query({
                "sql": """
                    INSERT INTO subscriptions
                    (id, device_id, plan_id, razorpay_payment_id, razorpay_subscription_id,
                     status, credits_total, credits_used, expires_at,
                     payment_provider, google_play_token)
                    VALUES (?, ?, ?, ?, ?, 'active', ?, 0, ?, ?, ?)
                """,
                "args": [sub_id, device_id, plan_id, razorpay_payment_id,
                         razorpay_subscription_id, credits, expires_at,
                         payment_provider, google_play_token]
            }, "create_subscription")

            # Verify the insert actually persisted
            verify = await self.query({
                "sql": "SELECT id FROM subscriptions WHERE id = ?",
                "args": [sub_id]
            }, "verify_subscription_insert")

            rows_affected = getattr(insert_result, 'rows_affected', 'unknown')

            if not verify.rows:
                logger.error(f"DB_SUB_INSERT_LOST: id={sub_id} device={device_id} plan={plan_id} rows_affected={rows_affected} — INSERT succeeded but row not found!")
                return None

            duration_ms = (time.time() - start_time) * 1000
            logger.info(f"DB_SUB_CREATED: id={sub_id} device={device_id} plan={plan_id} credits={credits} provider={payment_provider} expires_at={expires_at} rows_affected={rows_affected} verified=true duration_ms={duration_ms:.2f}")
            return sub_id
        except Exception as e:
            logger.error(f"DB_SUB_CREATE_FAILED: device={device_id} plan={plan_id} error={e}")
            return None

    async def find_by_razorpay_payment_id(self, razorpay_payment_id: str) -> Optional[str]:
        """Check if a subscription already exists for this payment (idempotency)."""
        client = self.get_client()
        if not client:
            return None
        try:
            result = await self.query({
                "sql": "SELECT id FROM subscriptions WHERE razorpay_payment_id = ? LIMIT 1",
                "args": [razorpay_payment_id]
            }, "find_sub_by_razorpay_payment")
            if result.rows:
                return self.extract_text(result.rows[0], 0)
            return None
        except Exception:
            return None

    async def find_by_razorpay_subscription_id(self, razorpay_subscription_id: str) -> Optional[str]:
        """Check if a subscription already exists for this Razorpay subscription (idempotency)."""
        client = self.get_client()
        if not client:
            return None
        try:
            result = await self.query({
                "sql": "SELECT id FROM subscriptions WHERE razorpay_subscription_id = ? LIMIT 1",
                "args": [razorpay_subscription_id]
            }, "find_sub_by_razorpay_sub_id")
            if result.rows:
                return self.extract_text(result.rows[0], 0)
            return None
        except Exception:
            return None

    async def add_usage(self, subscription_id: str, amount: int) -> bool:
        """Increment credits_used for a subscription. Called at session end."""
        client = self.get_client()
        if not client:
            return False

        try:
            await self.query({
                "sql": "UPDATE subscriptions SET credits_used = credits_used + ? WHERE id = ?",
                "args": [amount, subscription_id]
            }, "add_subscription_usage")

            logger.info(f"DB_SUB_USAGE: id={subscription_id} added={amount}")
            return True
        except Exception as e:
            logger.error(f"DB_SUB_USAGE_FAILED: id={subscription_id} error={e}")
            return False

    async def update_status_by_google_play_token(self, purchase_token: str, status: str, expires_at: int = None, reset_credits: int = None) -> bool:
        """Update subscription status by Google Play purchase token (for webhooks).

        If reset_credits is provided (on renewal), also resets credits_remaining.
        Returns False if no matching subscription was found.
        """
        client = self.get_client()
        if not client:
            return False

        try:
            # Check if subscription exists first
            exists = await self.query({
                "sql": "SELECT id FROM subscriptions WHERE google_play_token = ? LIMIT 1",
                "args": [purchase_token]
            }, "check_sub_by_google_play_token")

            if not exists.rows:
                return False

            if reset_credits is not None and expires_at:
                await self.query({
                    "sql": "UPDATE subscriptions SET status = ?, expires_at = ?, credits_total = ?, credits_used = 0 WHERE google_play_token = ?",
                    "args": [status, expires_at, reset_credits, purchase_token]
                }, "update_sub_by_google_play_token")
            elif expires_at:
                await self.query({
                    "sql": "UPDATE subscriptions SET status = ?, expires_at = ? WHERE google_play_token = ?",
                    "args": [status, expires_at, purchase_token]
                }, "update_sub_by_google_play_token")
            else:
                await self.query({
                    "sql": "UPDATE subscriptions SET status = ? WHERE google_play_token = ?",
                    "args": [status, purchase_token]
                }, "update_sub_by_google_play_token")

            logger.info(f"DB_SUB_GOOGLE_PLAY_UPDATED: token={purchase_token[:20]}... status={status} reset_credits={reset_credits}")
            return True
        except Exception as e:
            logger.error(f"DB_SUB_GOOGLE_PLAY_UPDATE_FAILED: token={purchase_token[:20]}... error={e}")
            return False

    async def update_by_razorpay_sub_id(
        self, razorpay_subscription_id: str, status: str,
        expires_at: int = None, reset_credits: int = None
    ) -> bool:
        """Update subscription by Razorpay subscription ID (for webhooks).

        On renewal (subscription.charged): resets credits and extends expiry.
        On cancel/pause: just updates status.
        Returns False if no matching subscription was found.
        """
        client = self.get_client()
        if not client:
            return False

        try:
            # Check if subscription exists first
            exists = await self.query({
                "sql": "SELECT id FROM subscriptions WHERE razorpay_subscription_id = ? LIMIT 1",
                "args": [razorpay_subscription_id]
            }, "check_sub_by_razorpay_sub_id")

            if not exists.rows:
                return False

            if reset_credits is not None and expires_at:
                await self.query({
                    "sql": """
                        UPDATE subscriptions
                        SET status = ?, expires_at = ?, credits_total = ?, credits_used = 0
                        WHERE razorpay_subscription_id = ?
                    """,
                    "args": [status, expires_at, reset_credits, razorpay_subscription_id]
                }, "update_sub_by_razorpay_sub_id")
            elif expires_at:
                await self.query({
                    "sql": "UPDATE subscriptions SET status = ?, expires_at = ? WHERE razorpay_subscription_id = ?",
                    "args": [status, expires_at, razorpay_subscription_id]
                }, "update_sub_by_razorpay_sub_id")
            else:
                await self.query({
                    "sql": "UPDATE subscriptions SET status = ? WHERE razorpay_subscription_id = ?",
                    "args": [status, razorpay_subscription_id]
                }, "update_sub_by_razorpay_sub_id")

            logger.info(f"DB_SUB_RAZORPAY_UPDATED: sub_id={razorpay_subscription_id} status={status} reset_credits={reset_credits}")
            return True
        except Exception as e:
            logger.error(f"DB_SUB_RAZORPAY_UPDATE_FAILED: sub_id={razorpay_subscription_id} error={e}")
            return False

    async def expire_subscription(self, subscription_id: str) -> bool:
        """Expire a specific subscription (when replaced by a new one)."""
        client = self.get_client()
        if not client:
            return False

        try:
            await self.query({
                "sql": "UPDATE subscriptions SET status = 'expired' WHERE id = ?",
                "args": [subscription_id]
            }, "expire_subscription")
            logger.info(f"DB_SUB_EXPIRED_REPLACED: id={subscription_id}")
            return True
        except Exception as e:
            logger.error(f"DB_SUB_EXPIRE_REPLACE_FAILED: id={subscription_id} error={e}")
            return False

    async def expire_old(self) -> int:
        """Expire subscriptions past their expires_at. Returns count updated."""
        client = self.get_client()
        if not client:
            return 0

        now = int(datetime.now(timezone.utc).timestamp())
        try:
            result = await self.query({
                "sql": "UPDATE subscriptions SET status = 'expired' WHERE status = 'active' AND expires_at <= ?",
                "args": [now]
            }, "expire_old_subscriptions")
            count = result.rows_affected if hasattr(result, 'rows_affected') else 0
            if count:
                logger.info(f"DB_SUB_EXPIRED: count={count}")
            return count
        except Exception as e:
            logger.error(f"DB_SUB_EXPIRE_FAILED: error={e}")
            return 0

subscription_repo = SubscriptionRepository()


I see the issue now. Look at line 37 and 52:

```sql
WHERE s.device_id = ? AND p.content_type = ?
  AND s.status = 'active' AND s.expires_at > ?
```

It checks **both** `status = 'active'` **AND** `expires_at > now`. The problem is — if a subscription's `expires_at` hasn't passed but the `status` was changed to something other than `'active'` (e.g. by a webhook setting it to `cancelled` or `paused`), it won't be found. And vice versa — if the status is still `'active'` but `expires_at` has passed, it also won't be found.

The real issue you're pointing out: **we should only check `expires_at`, not `status`**. Because:
- A sub with `status='active'` but `expires_at` in the past should NOT be valid
- A sub with `status='cancelled'` but `expires_at` still in the future SHOULD still be valid (user paid until that date)

Want me to change the query to only check `expires_at > now` and drop the `status = 'active'` check? That way a user keeps access until their subscription actually expires, regardless of status.


yeah do


Edit

            if payment_provider:
                result = await self.query({
                    "sql": """
                        SELECT s.id, s.plan_id, s.credits_total, s.credits_used,
                               s.expires_at, s.status, s.started_at, p.content_type
                        FROM subscriptions s
                        JOIN persona_plans p ON s.plan_id = p.plan_id
                        WHERE s.device_id = ? AND p.content_type = ?
                          AND s.status = 'active' AND s.expires_at > ?
                          AND s.payment_provider = ?
                        ORDER BY s.expires_at DESC
                        LIMIT 1
                    """,
                    "args": [device_id, content_type, now, payment_provider]
                }, "get_active_subscription")
            else:
                result = await self.query({
                    "sql": """
                        SELECT s.id, s.plan_id, s.credits_total, s.credits_used,
                               s.expires_at, s.status, s.started_at, p.content_type
                        FROM subscriptions s
                        JOIN persona_plans p ON s.plan_id = p.plan_id
                        WHERE s.device_id = ? AND p.content_type = ?
                          AND s.status = 'active' AND s.expires_at > ?
                        ORDER BY s.expires_at DESC
                        LIMIT 1
                    """,
                    "args": [device_id, content_type, now]
                }, "get_active_subscription")

            if payment_provider:
                result = await self.query({
                    "sql": """
                        SELECT s.id, s.plan_id, s.credits_total, s.credits_used,
                               s.expires_at, s.status, s.started_at, p.content_type
                        FROM subscriptions s
                        JOIN persona_plans p ON s.plan_id = p.plan_id
                        WHERE s.device_id = ? AND p.content_type = ?
                          AND s.expires_at > ?
                          AND s.payment_provider = ?
                        ORDER BY s.expires_at DESC
                        LIMIT 1
                    """,
                    "args": [device_id, content_type, now, payment_provider]
                }, "get_active_subscription")
            else:
                result = await self.query({
                    "sql": """
                        SELECT s.id, s.plan_id, s.credits_total, s.credits_used,
                               s.expires_at, s.status, s.started_at, p.content_type
                        FROM subscriptions s
                        JOIN persona_plans p ON s.plan_id = p.plan_id
                        WHERE s.device_id = ? AND p.content_type = ?
                          AND s.expires_at > ?
                        ORDER BY s.expires_at DESC
                        LIMIT 1
                    """,
                    "args": [device_id, content_type, now]
                }, "get_active_subscription")