Merge pull request #1480 from MODSetter/dev_mod

feat: unify credits system
This commit is contained in:
Rohan Verma 2026-06-11 13:32:47 -07:00 committed by GitHub
commit d27616ad0a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
88 changed files with 3290 additions and 2275 deletions

View file

@ -166,25 +166,26 @@ EMBEDDING_MODEL=sentence-transformers/all-MiniLM-L6-v2
# REDIS_URL=redis://redis:6379/0 # REDIS_URL=redis://redis:6379/0
# ------------------------------------------------------------------------------ # ------------------------------------------------------------------------------
# Stripe (pay-as-you-go page packs, disabled by default) # Stripe (unified credit wallet, disabled by default)
# ------------------------------------------------------------------------------ # ------------------------------------------------------------------------------
# Set TRUE to allow users to buy additional page packs via Stripe Checkout # Set TRUE to allow users to buy credit packs via Stripe Checkout. $1 buys
STRIPE_PAGE_BUYING_ENABLED=FALSE # 1_000_000 micro-USD of credit; both ETL page processing and premium turns
# debit this balance at the actual per-call provider cost from LiteLLM.
STRIPE_CREDIT_BUYING_ENABLED=FALSE
# STRIPE_SECRET_KEY=sk_test_... # STRIPE_SECRET_KEY=sk_test_...
# STRIPE_WEBHOOK_SECRET=whsec_... # STRIPE_WEBHOOK_SECRET=whsec_...
# STRIPE_PRICE_ID=price_... # STRIPE_CREDIT_PRICE_ID=price_...
# STRIPE_PAGES_PER_UNIT=1000 # STRIPE_CREDIT_MICROS_PER_UNIT=1000000
# STRIPE_RECONCILIATION_INTERVAL=10m # STRIPE_RECONCILIATION_INTERVAL=10m
# STRIPE_RECONCILIATION_LOOKBACK_MINUTES=10 # STRIPE_RECONCILIATION_LOOKBACK_MINUTES=10
# STRIPE_RECONCILIATION_BATCH_SIZE=100 # STRIPE_RECONCILIATION_BATCH_SIZE=100
# Premium credit purchases via Stripe ($1 buys 1_000_000 micro-USD of # Auto-reload: top up via a saved Stripe card when the balance drops below
# credit; premium turns debit the actual per-call provider cost # the user-chosen threshold. Off by default.
# reported by LiteLLM, so cheap and expensive models bill proportionally) # AUTO_RELOAD_ENABLED=FALSE
# STRIPE_TOKEN_BUYING_ENABLED=FALSE # AUTO_RELOAD_MIN_AMOUNT_MICROS=1000000
# STRIPE_PREMIUM_TOKEN_PRICE_ID=price_... # AUTO_RELOAD_COOLDOWN_MINUTES=10
# STRIPE_CREDIT_MICROS_PER_UNIT=1000000
# ------------------------------------------------------------------------------ # ------------------------------------------------------------------------------
# TTS & STT (Text-to-Speech / Speech-to-Text) # TTS & STT (Text-to-Speech / Speech-to-Text)
@ -407,13 +408,16 @@ SURFSENSE_ENABLE_DOOM_LOOP=true
# ACCESS_TOKEN_LIFETIME_SECONDS=86400 # ACCESS_TOKEN_LIFETIME_SECONDS=86400
# REFRESH_TOKEN_LIFETIME_SECONDS=1209600 # REFRESH_TOKEN_LIFETIME_SECONDS=1209600
# Pages limit per user for ETL (default: unlimited) # Unified credit wallet starting balance for new users, in micro-USD
# PAGES_LIMIT=500 # (default: $5). Funds both ETL page processing and premium model calls,
# debited at the actual per-call provider cost reported by LiteLLM.
# DEFAULT_CREDIT_MICROS_BALANCE=5000000
# Premium credit quota per registered user, in micro-USD (default: $5). # Debit the credit wallet for ETL page processing. Default FALSE keeps ETL
# Premium turns are debited at the actual per-call provider cost reported # effectively free for self-hosted installs. 1 page == MICROS_PER_PAGE
# by LiteLLM. Only applies to models with billing_tier=premium. # micro-USD ($0.001); premium ETL mode is 10x.
# PREMIUM_CREDIT_MICROS_LIMIT=5000000 # ETL_CREDIT_BILLING_ENABLED=FALSE
# MICROS_PER_PAGE=1000
# Safety ceiling on per-call premium reservation, in micro-USD ($1.00 default). # Safety ceiling on per-call premium reservation, in micro-USD ($1.00 default).
# QUOTA_MAX_RESERVE_MICROS=1000000 # QUOTA_MAX_RESERVE_MICROS=1000000

View file

@ -75,23 +75,16 @@ SECRET_KEY=SECRET
NEXT_FRONTEND_URL=http://localhost:3000 NEXT_FRONTEND_URL=http://localhost:3000
# Stripe Checkout for pay-as-you-go page packs # Stripe Checkout for the unified credit wallet.
# Configure STRIPE_PRICE_ID to point at your 1,000-page price in Stripe. # Each pack grants STRIPE_CREDIT_MICROS_PER_UNIT micro-USD of credit
# Pages granted per purchase = quantity * STRIPE_PAGES_PER_UNIT. # (default 1_000_000 = $1.00). Both ETL page processing and premium model
# turns are billed against this single balance at actual provider cost.
STRIPE_SECRET_KEY=sk_test_... STRIPE_SECRET_KEY=sk_test_...
STRIPE_WEBHOOK_SECRET=whsec_... STRIPE_WEBHOOK_SECRET=whsec_...
STRIPE_PRICE_ID=price_... STRIPE_CREDIT_PRICE_ID=price_...
STRIPE_PAGES_PER_UNIT=1000
# Set FALSE to disable new checkout session creation temporarily
STRIPE_PAGE_BUYING_ENABLED=TRUE
# Premium credit purchases via Stripe (for premium-tier model usage).
# Each pack grants STRIPE_CREDIT_MICROS_PER_UNIT micro-USD of credit
# (default 1_000_000 = $1.00). Premium turns are billed at the actual
# per-call provider cost reported by LiteLLM.
STRIPE_TOKEN_BUYING_ENABLED=FALSE
STRIPE_PREMIUM_TOKEN_PRICE_ID=price_...
STRIPE_CREDIT_MICROS_PER_UNIT=1000000 STRIPE_CREDIT_MICROS_PER_UNIT=1000000
# Set FALSE to disable new checkout session creation temporarily
STRIPE_CREDIT_BUYING_ENABLED=FALSE
# Periodic Stripe safety net for purchases left in PENDING (minutes old) # Periodic Stripe safety net for purchases left in PENDING (minutes old)
STRIPE_RECONCILIATION_LOOKBACK_MINUTES=10 STRIPE_RECONCILIATION_LOOKBACK_MINUTES=10
@ -221,15 +214,25 @@ VIDEO_PRESENTATION_FPS=30
VIDEO_PRESENTATION_DEFAULT_DURATION_IN_FRAMES=300 VIDEO_PRESENTATION_DEFAULT_DURATION_IN_FRAMES=300
# (Optional) Maximum pages limit per user for ETL services (default: `999999999` for unlimited in OSS version) # Unified credit wallet starting balance for new users, in micro-USD
PAGES_LIMIT=500 # (default: 5,000,000 == $5.00). The same balance funds ETL page processing
# and premium model calls, debited at actual provider cost.
DEFAULT_CREDIT_MICROS_BALANCE=5000000
# Premium credit quota per registered user, in micro-USD # Debit the credit wallet for ETL page processing. Default FALSE keeps ETL
# (default: 5,000,000 == $5.00 of credit). Premium turns are debited at the # effectively free for self-hosted/OSS installs; hosted deployments set TRUE.
# actual per-call provider cost reported by LiteLLM, so cheap and expensive # 1 page == MICROS_PER_PAGE micro-USD ($0.001); premium ETL mode is 10x.
# models bill proportionally. Applies only to models with ETL_CREDIT_BILLING_ENABLED=FALSE
# billing_tier=premium in global_llm_config.yaml. MICROS_PER_PAGE=1000
PREMIUM_CREDIT_MICROS_LIMIT=5000000
# Low-balance warning threshold (micro-USD), surfaced to the UI. Default $0.50.
CREDIT_LOW_BALANCE_WARNING_MICROS=500000
# Auto-reload: automatically top up via a saved Stripe card when the balance
# drops below the user-chosen threshold. Off by default.
AUTO_RELOAD_ENABLED=FALSE
AUTO_RELOAD_MIN_AMOUNT_MICROS=1000000
AUTO_RELOAD_COOLDOWN_MINUTES=10
# Safety ceiling on per-call premium reservation, in micro-USD. # Safety ceiling on per-call premium reservation, in micro-USD.
# stream_new_chat estimates an upper-bound cost from the model's # stream_new_chat estimates an upper-bound cost from the model's

View file

@ -0,0 +1,235 @@
"""unify page limits and premium credits into a single credit_micros_balance wallet
Collapses the two separate economies (ETL ``pages_limit``/``pages_used`` and
premium ``premium_credit_micros_limit``/``premium_credit_micros_used``) into one
USD-micro wallet column ``user.credit_micros_balance`` that decreases on use and
increases on purchase / grant. ``premium_credit_micros_reserved`` is kept (renamed
to ``credit_micros_reserved``) for in-flight reservation holds.
Backfill (per existing user row):
balance = GREATEST(0, premium_credit_micros_limit - premium_credit_micros_used)
+ (CASE WHEN pages_limit < 100000000
THEN GREATEST(0, pages_limit - pages_used) * 1000
ELSE 0 END)
The ``pages_limit < 100000000`` guard skips the OSS "unlimited" default
(``PAGES_LIMIT=999999999``) so self-hosters don't get a ~$1M credit grant.
1 page == 1000 micros == $0.001 (matches the prior $1 / 1000 pages price).
Table / type renames:
premium_token_purchases -> credit_purchases
premiumtokenpurchasestatus (enum)-> creditpurchasestatus
user_incentive_tasks.pages_awarded -> credit_micros_awarded (backfilled * 1000)
The "user" table is in zero_publication's column list, so this migration updates
the publication via ``apply_publication`` (canonical reconcile, per migration 155)
BEFORE dropping the old columns it referenced.
IMPORTANT - before AND after running this migration (same as migration 140):
1. Stop zero-cache (it holds replication locks that will deadlock DDL)
2. Run: alembic upgrade head
3. Delete / reset the zero-cache data volume
4. Restart zero-cache (it will do a fresh initial sync)
Revision ID: 156
Revises: 155
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
from app.zero_publication import apply_publication
revision: str = "156"
down_revision: str | None = "155"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def _column_exists(conn, table: str, column: str) -> bool:
return (
conn.execute(
sa.text(
"SELECT 1 FROM information_schema.columns "
"WHERE table_name = :tbl AND column_name = :col "
"AND table_schema = current_schema()"
),
{"tbl": table, "col": column},
).fetchone()
is not None
)
def _table_exists(conn, table: str) -> bool:
return (
conn.execute(
sa.text(
"SELECT 1 FROM information_schema.tables "
"WHERE table_name = :tbl AND table_schema = current_schema()"
),
{"tbl": table},
).fetchone()
is not None
)
def _terminate_blocked_pids(conn, table: str) -> None:
"""Kill backends whose locks on *table* would block our AccessExclusiveLock."""
conn.execute(
sa.text(
"SELECT pg_terminate_backend(l.pid) "
"FROM pg_locks l "
"JOIN pg_class c ON c.oid = l.relation "
"WHERE c.relname = :tbl "
" AND l.pid != pg_backend_pid()"
),
{"tbl": table},
)
def upgrade() -> None:
conn = op.get_bind()
# ------------------------------------------------------------------
# 1. Add credit_micros_balance + backfill from both legacy economies.
# ------------------------------------------------------------------
if not _column_exists(conn, "user", "credit_micros_balance"):
op.add_column(
"user",
sa.Column(
"credit_micros_balance",
sa.BigInteger(),
nullable=False,
server_default="5000000",
),
)
# Backfill only when ALL legacy source columns are present (fresh DBs
# created from current models won't have them).
if all(
_column_exists(conn, "user", col)
for col in (
"premium_credit_micros_limit",
"premium_credit_micros_used",
"pages_limit",
"pages_used",
)
):
conn.execute(
sa.text(
'UPDATE "user" SET credit_micros_balance = '
"GREATEST(0, premium_credit_micros_limit - premium_credit_micros_used) "
"+ (CASE WHEN pages_limit < 100000000 "
" THEN GREATEST(0, pages_limit - pages_used) * 1000 "
" ELSE 0 END)"
)
)
# ------------------------------------------------------------------
# 2. Rename premium_credit_micros_reserved -> credit_micros_reserved.
# ------------------------------------------------------------------
if _column_exists(
conn, "user", "premium_credit_micros_reserved"
) and not _column_exists(conn, "user", "credit_micros_reserved"):
op.alter_column(
"user",
"premium_credit_micros_reserved",
new_column_name="credit_micros_reserved",
)
# ------------------------------------------------------------------
# 3. Reconcile the Zero publication to the new column list
# (id, credit_micros_balance) BEFORE dropping the columns it used
# to reference, otherwise Postgres rejects the column drops with
# "cannot drop column ... referenced by publication".
# ------------------------------------------------------------------
conn.execute(sa.text("SET lock_timeout = '10s'"))
_terminate_blocked_pids(conn, "user")
apply_publication(conn)
# ------------------------------------------------------------------
# 4. Drop the legacy quota columns now that nothing references them.
# ------------------------------------------------------------------
for col in (
"premium_credit_micros_limit",
"premium_credit_micros_used",
"pages_limit",
"pages_used",
):
if _column_exists(conn, "user", col):
op.drop_column("user", col)
# ------------------------------------------------------------------
# 5. Rename premium_token_purchases -> credit_purchases and its enum.
# ------------------------------------------------------------------
op.execute(
"""
DO $$
BEGIN
IF EXISTS (
SELECT 1 FROM pg_type t
JOIN pg_namespace n ON n.oid = t.typnamespace
WHERE t.typname = 'premiumtokenpurchasestatus'
AND n.nspname = current_schema()
)
AND NOT EXISTS (
SELECT 1 FROM pg_type t
JOIN pg_namespace n ON n.oid = t.typnamespace
WHERE t.typname = 'creditpurchasestatus'
AND n.nspname = current_schema()
)
THEN
ALTER TYPE premiumtokenpurchasestatus RENAME TO creditpurchasestatus;
END IF;
END
$$;
"""
)
if _table_exists(conn, "premium_token_purchases") and not _table_exists(
conn, "credit_purchases"
):
op.rename_table("premium_token_purchases", "credit_purchases")
# ``source`` distinguishes user checkout from auto-reload top-ups.
if _table_exists(conn, "credit_purchases") and not _column_exists(
conn, "credit_purchases", "source"
):
op.add_column(
"credit_purchases",
sa.Column(
"source",
sa.String(length=20),
nullable=False,
server_default="checkout",
),
)
# ------------------------------------------------------------------
# 6. Rename user_incentive_tasks.pages_awarded -> credit_micros_awarded
# and convert page counts to micros (1 page == 1000 micros).
# ------------------------------------------------------------------
if _column_exists(
conn, "user_incentive_tasks", "pages_awarded"
) and not _column_exists(conn, "user_incentive_tasks", "credit_micros_awarded"):
op.alter_column(
"user_incentive_tasks",
"pages_awarded",
new_column_name="credit_micros_awarded",
type_=sa.BigInteger(),
)
conn.execute(
sa.text(
"UPDATE user_incentive_tasks "
"SET credit_micros_awarded = credit_micros_awarded * 1000"
)
)
def downgrade() -> None:
"""No-op. This is a one-way data-model unification; the legacy split
columns cannot be faithfully reconstructed from a single balance."""

View file

@ -0,0 +1,92 @@
"""add auto-reload (off-session Stripe top-up) columns to user
Adds the saved-card + threshold plumbing that powers feature-flagged credit
auto-reload (``AUTO_RELOAD_ENABLED``):
user.stripe_customer_id (text, nullable)
user.auto_reload_enabled (bool, default false)
user.auto_reload_threshold_micros (bigint, nullable)
user.auto_reload_amount_micros (bigint, nullable)
user.auto_reload_payment_method_id (text, nullable)
user.auto_reload_failed_at (timestamptz, nullable)
None of these columns are part of the Zero publication (``USER_COLS`` is
``["id", "credit_micros_balance"]``), so this migration does NOT touch the
publication and is safe to run without the zero-cache stop/reset dance that
migration 156 required.
The ``credit_purchases.source`` column (``checkout`` | ``auto_reload``) was
already added in migration 156, so it is not repeated here.
Revision ID: 157
Revises: 156
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
revision: str = "157"
down_revision: str | None = "156"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def _column_exists(conn, table: str, column: str) -> bool:
return (
conn.execute(
sa.text(
"SELECT 1 FROM information_schema.columns "
"WHERE table_name = :tbl AND column_name = :col "
"AND table_schema = current_schema()"
),
{"tbl": table, "col": column},
).fetchone()
is not None
)
_COLUMNS: list[tuple[str, sa.Column]] = [
("stripe_customer_id", sa.Column("stripe_customer_id", sa.String(), nullable=True)),
(
"auto_reload_enabled",
sa.Column(
"auto_reload_enabled",
sa.Boolean(),
nullable=False,
server_default=sa.text("false"),
),
),
(
"auto_reload_threshold_micros",
sa.Column("auto_reload_threshold_micros", sa.BigInteger(), nullable=True),
),
(
"auto_reload_amount_micros",
sa.Column("auto_reload_amount_micros", sa.BigInteger(), nullable=True),
),
(
"auto_reload_payment_method_id",
sa.Column("auto_reload_payment_method_id", sa.String(), nullable=True),
),
(
"auto_reload_failed_at",
sa.Column("auto_reload_failed_at", sa.TIMESTAMP(timezone=True), nullable=True),
),
]
def upgrade() -> None:
conn = op.get_bind()
for name, column in _COLUMNS:
if not _column_exists(conn, "user", name):
op.add_column("user", column)
def downgrade() -> None:
conn = op.get_bind()
for name, _ in reversed(_COLUMNS):
if _column_exists(conn, "user", name):
op.drop_column("user", name)

View file

@ -189,6 +189,7 @@ celery_app = Celery(
"app.tasks.celery_tasks.document_reindex_tasks", "app.tasks.celery_tasks.document_reindex_tasks",
"app.tasks.celery_tasks.stale_notification_cleanup_task", "app.tasks.celery_tasks.stale_notification_cleanup_task",
"app.tasks.celery_tasks.stripe_reconciliation_task", "app.tasks.celery_tasks.stripe_reconciliation_task",
"app.tasks.celery_tasks.auto_reload_task",
"app.tasks.celery_tasks.gateway_tasks", "app.tasks.celery_tasks.gateway_tasks",
"app.automations.tasks.execute_run", "app.automations.tasks.execute_run",
"app.automations.triggers.builtin.schedule.selector", "app.automations.triggers.builtin.schedule.selector",
@ -281,16 +282,9 @@ celery_app.conf.beat_schedule = {
"expires": 60, # Task expires after 60 seconds if not picked up "expires": 60, # Task expires after 60 seconds if not picked up
}, },
}, },
# Reconcile Stripe purchases that were paid but remained pending # Reconcile Stripe credit purchases that were paid but remained pending
"reconcile-pending-stripe-page-purchases": { "reconcile-pending-stripe-credit-purchases": {
"task": "reconcile_pending_stripe_page_purchases", "task": "reconcile_pending_stripe_credit_purchases",
"schedule": crontab(**stripe_reconciliation_schedule_params),
"options": {
"expires": 60,
},
},
"reconcile-pending-stripe-token-purchases": {
"task": "reconcile_pending_stripe_token_purchases",
"schedule": crontab(**stripe_reconciliation_schedule_params), "schedule": crontab(**stripe_reconciliation_schedule_params),
"options": { "options": {
"expires": 60, "expires": 60,

View file

@ -640,14 +640,9 @@ class Config:
) )
GATEWAY_DISCORD_REDIRECT_URI = os.getenv("GATEWAY_DISCORD_REDIRECT_URI") GATEWAY_DISCORD_REDIRECT_URI = os.getenv("GATEWAY_DISCORD_REDIRECT_URI")
# Stripe checkout for pay-as-you-go page packs # Stripe checkout (shared secrets for the unified credit wallet)
STRIPE_SECRET_KEY = os.getenv("STRIPE_SECRET_KEY") STRIPE_SECRET_KEY = os.getenv("STRIPE_SECRET_KEY")
STRIPE_WEBHOOK_SECRET = os.getenv("STRIPE_WEBHOOK_SECRET") STRIPE_WEBHOOK_SECRET = os.getenv("STRIPE_WEBHOOK_SECRET")
STRIPE_PRICE_ID = os.getenv("STRIPE_PRICE_ID")
STRIPE_PAGES_PER_UNIT = int(os.getenv("STRIPE_PAGES_PER_UNIT", "1000"))
STRIPE_PAGE_BUYING_ENABLED = (
os.getenv("STRIPE_PAGE_BUYING_ENABLED", "TRUE").upper() == "TRUE"
)
STRIPE_RECONCILIATION_LOOKBACK_MINUTES = int( STRIPE_RECONCILIATION_LOOKBACK_MINUTES = int(
os.getenv("STRIPE_RECONCILIATION_LOOKBACK_MINUTES", "10") os.getenv("STRIPE_RECONCILIATION_LOOKBACK_MINUTES", "10")
) )
@ -655,27 +650,56 @@ class Config:
os.getenv("STRIPE_RECONCILIATION_BATCH_SIZE", "100") os.getenv("STRIPE_RECONCILIATION_BATCH_SIZE", "100")
) )
# Premium credit (micro-USD) quota settings. # Unified credit wallet (micro-USD) settings.
# #
# Storage unit is integer micro-USD (1_000_000 = $1.00). The legacy # Storage unit is integer micro-USD (1_000_000 = $1.00). A single
# ``PREMIUM_TOKEN_LIMIT`` and ``STRIPE_TOKENS_PER_UNIT`` env vars are # ``credit_micros_balance`` funds both ETL page processing and premium
# still honoured for one release as fall-back values — the prior # model calls. New users start with ``DEFAULT_CREDIT_MICROS_BALANCE``
# $1-per-1M-tokens Stripe price means every existing value maps 1:1 # ($5 by default).
# to micros, so operators upgrading without changing their .env still #
# get correct behaviour. A startup deprecation warning fires below if # Legacy env names (``PREMIUM_CREDIT_MICROS_LIMIT`` / ``PREMIUM_TOKEN_LIMIT``,
# they're set. # ``STRIPE_PREMIUM_TOKEN_PRICE_ID``, ``STRIPE_CREDIT_MICROS_PER_UNIT`` /
PREMIUM_CREDIT_MICROS_LIMIT = int( # ``STRIPE_TOKENS_PER_UNIT``, ``STRIPE_TOKEN_BUYING_ENABLED``) are still
os.getenv("PREMIUM_CREDIT_MICROS_LIMIT") # honoured as fall-backs for one release; deprecation warnings fire below.
DEFAULT_CREDIT_MICROS_BALANCE = int(
os.getenv("DEFAULT_CREDIT_MICROS_BALANCE")
or os.getenv("PREMIUM_CREDIT_MICROS_LIMIT")
or os.getenv("PREMIUM_TOKEN_LIMIT", "5000000") or os.getenv("PREMIUM_TOKEN_LIMIT", "5000000")
) )
STRIPE_PREMIUM_TOKEN_PRICE_ID = os.getenv("STRIPE_PREMIUM_TOKEN_PRICE_ID") STRIPE_CREDIT_PRICE_ID = os.getenv("STRIPE_CREDIT_PRICE_ID") or os.getenv(
"STRIPE_PREMIUM_TOKEN_PRICE_ID"
)
STRIPE_CREDIT_MICROS_PER_UNIT = int( STRIPE_CREDIT_MICROS_PER_UNIT = int(
os.getenv("STRIPE_CREDIT_MICROS_PER_UNIT") os.getenv("STRIPE_CREDIT_MICROS_PER_UNIT")
or os.getenv("STRIPE_TOKENS_PER_UNIT", "1000000") or os.getenv("STRIPE_TOKENS_PER_UNIT", "1000000")
) )
STRIPE_TOKEN_BUYING_ENABLED = ( STRIPE_CREDIT_BUYING_ENABLED = (
os.getenv("STRIPE_TOKEN_BUYING_ENABLED", "FALSE").upper() == "TRUE" os.getenv("STRIPE_CREDIT_BUYING_ENABLED")
or os.getenv("STRIPE_TOKEN_BUYING_ENABLED", "FALSE")
).upper() == "TRUE"
# ETL page processing debits the credit wallet only when enabled. Defaults
# to FALSE so self-hosted / OSS installs keep effectively-free ETL; hosted
# deployments set this TRUE. 1 page == ``MICROS_PER_PAGE`` micro-USD.
ETL_CREDIT_BILLING_ENABLED = (
os.getenv("ETL_CREDIT_BILLING_ENABLED", "FALSE").upper() == "TRUE"
) )
MICROS_PER_PAGE = int(os.getenv("MICROS_PER_PAGE", "1000"))
# Low-balance WARNING threshold (micro-USD). Surfaced by the quota service
# so the UI can nudge the user to top up / enable auto-reload. $0.50.
CREDIT_LOW_BALANCE_WARNING_MICROS = int(
os.getenv("CREDIT_LOW_BALANCE_WARNING_MICROS", "500000")
)
# Auto-reload (off-session Stripe top-up) feature flag and guards.
AUTO_RELOAD_ENABLED = os.getenv("AUTO_RELOAD_ENABLED", "FALSE").upper() == "TRUE"
# Minimum configurable reload amount (micro-USD). $1.00 to match pack pricing.
AUTO_RELOAD_MIN_AMOUNT_MICROS = int(
os.getenv("AUTO_RELOAD_MIN_AMOUNT_MICROS", "1000000")
)
# Cooldown so a burst of debits can't fire multiple charges (minutes).
AUTO_RELOAD_COOLDOWN_MINUTES = int(os.getenv("AUTO_RELOAD_COOLDOWN_MINUTES", "10"))
# Safety ceiling on the per-call premium reservation. ``stream_new_chat`` # Safety ceiling on the per-call premium reservation. ``stream_new_chat``
# estimates an upper-bound cost from ``litellm.get_model_info`` x the # estimates an upper-bound cost from ``litellm.get_model_info`` x the
@ -685,14 +709,13 @@ class Config:
# reserve_tokens ≈ $0.36) with headroom. # reserve_tokens ≈ $0.36) with headroom.
QUOTA_MAX_RESERVE_MICROS = int(os.getenv("QUOTA_MAX_RESERVE_MICROS", "1000000")) QUOTA_MAX_RESERVE_MICROS = int(os.getenv("QUOTA_MAX_RESERVE_MICROS", "1000000"))
if os.getenv("PREMIUM_TOKEN_LIMIT") and not os.getenv( if (
"PREMIUM_CREDIT_MICROS_LIMIT" os.getenv("PREMIUM_TOKEN_LIMIT") or os.getenv("PREMIUM_CREDIT_MICROS_LIMIT")
): ) and not os.getenv("DEFAULT_CREDIT_MICROS_BALANCE"):
print( print(
"Warning: PREMIUM_TOKEN_LIMIT is deprecated; rename to " "Warning: PREMIUM_TOKEN_LIMIT / PREMIUM_CREDIT_MICROS_LIMIT are "
"PREMIUM_CREDIT_MICROS_LIMIT (1:1 numerical mapping under the " "deprecated; rename to DEFAULT_CREDIT_MICROS_BALANCE. The old keys "
"current Stripe price). The old key will be removed in a " "will be removed in a future release."
"future release."
) )
if os.getenv("STRIPE_TOKENS_PER_UNIT") and not os.getenv( if os.getenv("STRIPE_TOKENS_PER_UNIT") and not os.getenv(
"STRIPE_CREDIT_MICROS_PER_UNIT" "STRIPE_CREDIT_MICROS_PER_UNIT"
@ -702,6 +725,22 @@ class Config:
"STRIPE_CREDIT_MICROS_PER_UNIT (1:1 numerical mapping). " "STRIPE_CREDIT_MICROS_PER_UNIT (1:1 numerical mapping). "
"The old key will be removed in a future release." "The old key will be removed in a future release."
) )
if os.getenv("STRIPE_PREMIUM_TOKEN_PRICE_ID") and not os.getenv(
"STRIPE_CREDIT_PRICE_ID"
):
print(
"Warning: STRIPE_PREMIUM_TOKEN_PRICE_ID is deprecated; rename to "
"STRIPE_CREDIT_PRICE_ID. The old key will be removed in a future "
"release."
)
if os.getenv("STRIPE_TOKEN_BUYING_ENABLED") and not os.getenv(
"STRIPE_CREDIT_BUYING_ENABLED"
):
print(
"Warning: STRIPE_TOKEN_BUYING_ENABLED is deprecated; rename to "
"STRIPE_CREDIT_BUYING_ENABLED. The old key will be removed in a "
"future release."
)
# Anonymous / no-login mode settings # Anonymous / no-login mode settings
NOLOGIN_MODE_ENABLED = os.getenv("NOLOGIN_MODE_ENABLED", "FALSE").upper() == "TRUE" NOLOGIN_MODE_ENABLED = os.getenv("NOLOGIN_MODE_ENABLED", "FALSE").upper() == "TRUE"
@ -903,9 +942,6 @@ class Config:
# ETL Service # ETL Service
ETL_SERVICE = os.getenv("ETL_SERVICE") ETL_SERVICE = os.getenv("ETL_SERVICE")
# Pages limit for ETL services (default to very high number for OSS unlimited usage)
PAGES_LIMIT = int(os.getenv("PAGES_LIMIT", "999999999"))
if ETL_SERVICE == "UNSTRUCTURED": if ETL_SERVICE == "UNSTRUCTURED":
# Unstructured API Key # Unstructured API Key
UNSTRUCTURED_API_KEY = os.getenv("UNSTRUCTURED_API_KEY") UNSTRUCTURED_API_KEY = os.getenv("UNSTRUCTURED_API_KEY")

View file

@ -320,7 +320,7 @@ class PagePurchaseStatus(StrEnum):
FAILED = "failed" FAILED = "failed"
class PremiumTokenPurchaseStatus(StrEnum): class CreditPurchaseStatus(StrEnum):
PENDING = "pending" PENDING = "pending"
COMPLETED = "completed" COMPLETED = "completed"
FAILED = "failed" FAILED = "failed"
@ -332,26 +332,27 @@ INCENTIVE_TASKS_CONFIG = {
IncentiveTaskType.GITHUB_STAR: { IncentiveTaskType.GITHUB_STAR: {
"title": "Star our GitHub repository", "title": "Star our GitHub repository",
"description": "Show your support by starring SurfSense on GitHub", "description": "Show your support by starring SurfSense on GitHub",
"pages_reward": 30, # Credit reward in USD micro-units (1_000_000 == $1.00). $0.03.
"credit_micros_reward": 30000,
"action_url": "https://github.com/MODSetter/SurfSense", "action_url": "https://github.com/MODSetter/SurfSense",
}, },
IncentiveTaskType.REDDIT_FOLLOW: { IncentiveTaskType.REDDIT_FOLLOW: {
"title": "Join our Subreddit", "title": "Join our Subreddit",
"description": "Join the SurfSense community on Reddit", "description": "Join the SurfSense community on Reddit",
"pages_reward": 30, "credit_micros_reward": 30000,
"action_url": "https://www.reddit.com/r/SurfSense/", "action_url": "https://www.reddit.com/r/SurfSense/",
}, },
IncentiveTaskType.DISCORD_JOIN: { IncentiveTaskType.DISCORD_JOIN: {
"title": "Join our Discord", "title": "Join our Discord",
"description": "Join the SurfSense community on Discord", "description": "Join the SurfSense community on Discord",
"pages_reward": 40, "credit_micros_reward": 40000,
"action_url": "https://discord.gg/ejRNvftDp9", "action_url": "https://discord.gg/ejRNvftDp9",
}, },
# Future tasks can be configured here: # Future tasks can be configured here:
# IncentiveTaskType.GITHUB_ISSUE: { # IncentiveTaskType.GITHUB_ISSUE: {
# "title": "Create an issue", # "title": "Create an issue",
# "description": "Help improve SurfSense by reporting bugs or suggesting features", # "description": "Help improve SurfSense by reporting bugs or suggesting features",
# "pages_reward": 50, # "credit_micros_reward": 50000,
# "action_url": "https://github.com/MODSetter/SurfSense/issues/new/choose", # "action_url": "https://github.com/MODSetter/SurfSense/issues/new/choose",
# }, # },
} }
@ -2069,7 +2070,7 @@ class UserIncentiveTask(BaseModel, TimestampMixin):
""" """
Tracks completed incentive tasks for users. Tracks completed incentive tasks for users.
Each user can only complete each task type once. Each user can only complete each task type once.
When a task is completed, the user's pages_limit is increased. When a task is completed, the user's credit_micros_balance is increased.
""" """
__tablename__ = "user_incentive_tasks" __tablename__ = "user_incentive_tasks"
@ -2088,7 +2089,8 @@ class UserIncentiveTask(BaseModel, TimestampMixin):
index=True, index=True,
) )
task_type = Column(SQLAlchemyEnum(IncentiveTaskType), nullable=False, index=True) task_type = Column(SQLAlchemyEnum(IncentiveTaskType), nullable=False, index=True)
pages_awarded = Column(Integer, nullable=False) # Credit reward granted in USD micro-units (1_000_000 == $1.00).
credit_micros_awarded = Column(BigInteger, nullable=False)
completed_at = Column( completed_at = Column(
TIMESTAMP(timezone=True), TIMESTAMP(timezone=True),
nullable=False, nullable=False,
@ -2131,18 +2133,18 @@ class PagePurchase(Base, TimestampMixin):
user = relationship("User", back_populates="page_purchases") user = relationship("User", back_populates="page_purchases")
class PremiumTokenPurchase(Base, TimestampMixin): class CreditPurchase(Base, TimestampMixin):
"""Tracks Stripe checkout sessions used to grant additional premium credit (USD micro-units). """Tracks Stripe checkout sessions used to grant credit (USD micro-units).
Note: the table name is preserved (``premium_token_purchases``) for Renamed from ``premium_token_purchases`` in migration 156 as part of the
operational continuity even though the unit is now USD micro-credits unified-credits wallet. ``credit_micros_granted`` stores the USD-micro
instead of raw tokens. The ``credit_micros_granted`` column replaced amount added to ``user.credit_micros_balance`` on fulfillment.
the legacy ``tokens_granted`` in migration 140; the stored values
were not transformed because the prior $1 = 1M tokens Stripe price ``source`` distinguishes a user-initiated checkout from an automatic
makes the unit conversion 1:1 numerically. off-session top-up (auto-reload), added in the auto-reload migration.
""" """
__tablename__ = "premium_token_purchases" __tablename__ = "credit_purchases"
__allow_unmapped__ = True __allow_unmapped__ = True
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
@ -2160,15 +2162,18 @@ class PremiumTokenPurchase(Base, TimestampMixin):
credit_micros_granted = Column(BigInteger, nullable=False) credit_micros_granted = Column(BigInteger, nullable=False)
amount_total = Column(Integer, nullable=True) amount_total = Column(Integer, nullable=True)
currency = Column(String(10), nullable=True) currency = Column(String(10), nullable=True)
source = Column(
String(20), nullable=False, default="checkout", server_default="checkout"
)
status = Column( status = Column(
SQLAlchemyEnum(PremiumTokenPurchaseStatus), SQLAlchemyEnum(CreditPurchaseStatus),
nullable=False, nullable=False,
default=PremiumTokenPurchaseStatus.PENDING, default=CreditPurchaseStatus.PENDING,
index=True, index=True,
) )
completed_at = Column(TIMESTAMP(timezone=True), nullable=True) completed_at = Column(TIMESTAMP(timezone=True), nullable=True)
user = relationship("User", back_populates="premium_token_purchases") user = relationship("User", back_populates="credit_purchases")
class SearchSpaceRole(BaseModel, TimestampMixin): class SearchSpaceRole(BaseModel, TimestampMixin):
@ -2448,33 +2453,40 @@ if config.AUTH_TYPE == "GOOGLE":
back_populates="user", back_populates="user",
cascade="all, delete-orphan", cascade="all, delete-orphan",
) )
premium_token_purchases = relationship( credit_purchases = relationship(
"PremiumTokenPurchase", "CreditPurchase",
back_populates="user", back_populates="user",
cascade="all, delete-orphan", cascade="all, delete-orphan",
) )
# Page usage tracking for ETL services # Unified credit wallet (USD micro-units, 1_000_000 == $1.00).
pages_limit = Column( # Decreases on use (ETL pages + premium model calls), increases on
Integer, # purchase / incentive grant / auto-reload. May dip slightly negative
nullable=False, # when an actual cost exceeds its pre-charge estimate; UI clamps at $0.
default=config.PAGES_LIMIT, credit_micros_balance = Column(
server_default=str(config.PAGES_LIMIT),
)
pages_used = Column(Integer, nullable=False, default=0, server_default="0")
premium_credit_micros_limit = Column(
BigInteger, BigInteger,
nullable=False, nullable=False,
default=config.PREMIUM_CREDIT_MICROS_LIMIT, default=config.DEFAULT_CREDIT_MICROS_BALANCE,
server_default=str(config.PREMIUM_CREDIT_MICROS_LIMIT), server_default=str(config.DEFAULT_CREDIT_MICROS_BALANCE),
) )
premium_credit_micros_used = Column( # In-flight reservation holds (released/settled at finalize).
credit_micros_reserved = Column(
BigInteger, nullable=False, default=0, server_default="0" BigInteger, nullable=False, default=0, server_default="0"
) )
premium_credit_micros_reserved = Column(
BigInteger, nullable=False, default=0, server_default="0" # Auto-reload (off-session Stripe top-up), behind AUTO_RELOAD_ENABLED.
# ``stripe_customer_id`` + ``auto_reload_payment_method_id`` are the
# saved-card plumbing; thresholds are micro-USD. ``auto_reload_failed_at``
# is set (and ``auto_reload_enabled`` flipped off) when an off-session
# charge is declined so the UI can prompt the user to fix their card.
stripe_customer_id = Column(String, nullable=True)
auto_reload_enabled = Column(
Boolean, nullable=False, default=False, server_default="false"
) )
auto_reload_threshold_micros = Column(BigInteger, nullable=True)
auto_reload_amount_micros = Column(BigInteger, nullable=True)
auto_reload_payment_method_id = Column(String, nullable=True)
auto_reload_failed_at = Column(TIMESTAMP(timezone=True), nullable=True)
# User profile from OAuth # User profile from OAuth
display_name = Column(String, nullable=True) display_name = Column(String, nullable=True)
@ -2587,33 +2599,40 @@ else:
back_populates="user", back_populates="user",
cascade="all, delete-orphan", cascade="all, delete-orphan",
) )
premium_token_purchases = relationship( credit_purchases = relationship(
"PremiumTokenPurchase", "CreditPurchase",
back_populates="user", back_populates="user",
cascade="all, delete-orphan", cascade="all, delete-orphan",
) )
# Page usage tracking for ETL services # Unified credit wallet (USD micro-units, 1_000_000 == $1.00).
pages_limit = Column( # Decreases on use (ETL pages + premium model calls), increases on
Integer, # purchase / incentive grant / auto-reload. May dip slightly negative
nullable=False, # when an actual cost exceeds its pre-charge estimate; UI clamps at $0.
default=config.PAGES_LIMIT, credit_micros_balance = Column(
server_default=str(config.PAGES_LIMIT),
)
pages_used = Column(Integer, nullable=False, default=0, server_default="0")
premium_credit_micros_limit = Column(
BigInteger, BigInteger,
nullable=False, nullable=False,
default=config.PREMIUM_CREDIT_MICROS_LIMIT, default=config.DEFAULT_CREDIT_MICROS_BALANCE,
server_default=str(config.PREMIUM_CREDIT_MICROS_LIMIT), server_default=str(config.DEFAULT_CREDIT_MICROS_BALANCE),
) )
premium_credit_micros_used = Column( # In-flight reservation holds (released/settled at finalize).
credit_micros_reserved = Column(
BigInteger, nullable=False, default=0, server_default="0" BigInteger, nullable=False, default=0, server_default="0"
) )
premium_credit_micros_reserved = Column(
BigInteger, nullable=False, default=0, server_default="0" # Auto-reload (off-session Stripe top-up), behind AUTO_RELOAD_ENABLED.
# ``stripe_customer_id`` + ``auto_reload_payment_method_id`` are the
# saved-card plumbing; thresholds are micro-USD. ``auto_reload_failed_at``
# is set (and ``auto_reload_enabled`` flipped off) when an off-session
# charge is declined so the UI can prompt the user to fix their card.
stripe_customer_id = Column(String, nullable=True)
auto_reload_enabled = Column(
Boolean, nullable=False, default=False, server_default="false"
) )
auto_reload_threshold_micros = Column(BigInteger, nullable=True)
auto_reload_amount_micros = Column(BigInteger, nullable=True)
auto_reload_payment_method_id = Column(String, nullable=True)
auto_reload_failed_at = Column(TIMESTAMP(timezone=True), nullable=True)
# User profile (can be set manually for non-OAuth users) # User profile (can be set manually for non-OAuth users)
display_name = Column(String, nullable=True) display_name = Column(String, nullable=True)

View file

@ -275,7 +275,7 @@ async def list_notifications(
query = query.where(unread_filter) query = query.where(unread_filter)
count_query = count_query.where(unread_filter) count_query = count_query.where(unread_filter)
elif filter == "errors": elif filter == "errors":
error_filter = (Notification.type == "page_limit_exceeded") | ( error_filter = (Notification.type == "insufficient_credits") | (
Notification.notification_metadata["status"].astext == "failed" Notification.notification_metadata["status"].astext == "failed"
) )
query = query.where(error_filter) query = query.where(error_filter)

View file

@ -12,6 +12,7 @@ CATEGORY_TYPES: dict[str, tuple[str, ...]] = {
"connector_indexing", "connector_indexing",
"connector_deletion", "connector_deletion",
"document_processing", "document_processing",
"page_limit_exceeded", "insufficient_credits",
"auto_reload_failed",
), ),
} }

View file

@ -10,11 +10,12 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.notifications.persistence import Notification from app.notifications.persistence import Notification
from app.notifications.service.handlers import ( from app.notifications.service.handlers import (
AutoReloadFailedNotificationHandler,
CommentReplyNotificationHandler, CommentReplyNotificationHandler,
ConnectorIndexingNotificationHandler, ConnectorIndexingNotificationHandler,
DocumentProcessingNotificationHandler, DocumentProcessingNotificationHandler,
InsufficientCreditsNotificationHandler,
MentionNotificationHandler, MentionNotificationHandler,
PageLimitNotificationHandler,
) )
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -27,7 +28,8 @@ class NotificationService:
document_processing = DocumentProcessingNotificationHandler() document_processing = DocumentProcessingNotificationHandler()
mention = MentionNotificationHandler() mention = MentionNotificationHandler()
comment_reply = CommentReplyNotificationHandler() comment_reply = CommentReplyNotificationHandler()
page_limit = PageLimitNotificationHandler() insufficient_credits = InsufficientCreditsNotificationHandler()
auto_reload_failed = AutoReloadFailedNotificationHandler()
@staticmethod @staticmethod
async def create_notification( async def create_notification(

View file

@ -2,16 +2,18 @@
from __future__ import annotations from __future__ import annotations
from .auto_reload_failed import AutoReloadFailedNotificationHandler
from .comment_reply import CommentReplyNotificationHandler from .comment_reply import CommentReplyNotificationHandler
from .connector_indexing import ConnectorIndexingNotificationHandler from .connector_indexing import ConnectorIndexingNotificationHandler
from .document_processing import DocumentProcessingNotificationHandler from .document_processing import DocumentProcessingNotificationHandler
from .insufficient_credits import InsufficientCreditsNotificationHandler
from .mention import MentionNotificationHandler from .mention import MentionNotificationHandler
from .page_limit import PageLimitNotificationHandler
__all__ = [ __all__ = [
"AutoReloadFailedNotificationHandler",
"CommentReplyNotificationHandler", "CommentReplyNotificationHandler",
"ConnectorIndexingNotificationHandler", "ConnectorIndexingNotificationHandler",
"DocumentProcessingNotificationHandler", "DocumentProcessingNotificationHandler",
"InsufficientCreditsNotificationHandler",
"MentionNotificationHandler", "MentionNotificationHandler",
"PageLimitNotificationHandler",
] ]

View file

@ -0,0 +1,54 @@
"""Notifications for failed off-session credit auto-reload charges."""
from __future__ import annotations
import logging
from uuid import UUID
from sqlalchemy.ext.asyncio import AsyncSession
from app.notifications.persistence import Notification
from app.notifications.service.base import BaseNotificationHandler
from app.notifications.service.messages import auto_reload_failed as msg
logger = logging.getLogger(__name__)
class AutoReloadFailedNotificationHandler(BaseNotificationHandler):
"""Notifications for declined auto-reload top-ups."""
def __init__(self):
super().__init__("auto_reload_failed")
async def notify_auto_reload_failed(
self,
session: AsyncSession,
user_id: UUID,
amount_micros: int,
payment_intent_id: str | None = None,
reason: str | None = None,
) -> Notification:
"""Notify that an off-session auto-reload charge was declined.
Not tied to a search space (``search_space_id`` is None); the action
links to the billing settings so the user can fix their card.
"""
op_id = msg.operation_id(payment_intent_id or "")
title, message = msg.summary(amount_micros, reason)
return await self.find_or_create_notification(
session=session,
user_id=user_id,
operation_id=op_id,
title=title,
message=message,
search_space_id=None,
initial_metadata={
"amount_micros": amount_micros,
"payment_intent_id": payment_intent_id,
"status": "failed",
"error_type": "auto_reload_failed",
"action_url": "/dashboard",
"action_label": "Update card",
},
)

View file

@ -1,4 +1,4 @@
"""Notifications for exceeding the page limit.""" """Notifications for running out of credit during document processing."""
from __future__ import annotations from __future__ import annotations
@ -9,46 +9,42 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.notifications.persistence import Notification from app.notifications.persistence import Notification
from app.notifications.service.base import BaseNotificationHandler from app.notifications.service.base import BaseNotificationHandler
from app.notifications.service.messages import page_limit as msg from app.notifications.service.messages import insufficient_credits as msg
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
class PageLimitNotificationHandler(BaseNotificationHandler): class InsufficientCreditsNotificationHandler(BaseNotificationHandler):
"""Notifications for exceeding the page limit.""" """Notifications for running out of credit during document processing."""
def __init__(self): def __init__(self):
super().__init__("page_limit_exceeded") super().__init__("insufficient_credits")
async def notify_page_limit_exceeded( async def notify_insufficient_credits(
self, self,
session: AsyncSession, session: AsyncSession,
user_id: UUID, user_id: UUID,
document_name: str, document_name: str,
document_type: str, document_type: str,
search_space_id: int, search_space_id: int,
pages_used: int, balance_micros: int,
pages_limit: int, required_micros: int,
pages_to_add: int,
) -> Notification: ) -> Notification:
"""Notify that a document was blocked by the page limit.""" """Notify that a document was blocked by insufficient credit."""
operation_id = msg.operation_id(document_name, search_space_id) operation_id = msg.operation_id(document_name, search_space_id)
title, message = msg.summary( title, message = msg.summary(document_name, balance_micros, required_micros)
document_name, pages_used, pages_limit, pages_to_add
)
metadata = { metadata = {
"operation_id": operation_id, "operation_id": operation_id,
"document_name": document_name, "document_name": document_name,
"document_type": document_type, "document_type": document_type,
"pages_used": pages_used, "balance_micros": balance_micros,
"pages_limit": pages_limit, "required_micros": required_micros,
"pages_to_add": pages_to_add,
"status": "failed", "status": "failed",
"error_type": "page_limit_exceeded", "error_type": "insufficient_credits",
# Where the inbox item links to. # Where the inbox item links to.
"action_url": f"/dashboard/{search_space_id}/more-pages", "action_url": f"/dashboard/{search_space_id}/buy-more",
"action_label": "Upgrade Plan", "action_label": "Buy credits",
} }
notification = Notification( notification = Notification(
@ -63,6 +59,7 @@ class PageLimitNotificationHandler(BaseNotificationHandler):
await session.commit() await session.commit()
await session.refresh(notification) await session.refresh(notification)
logger.info( logger.info(
f"Created page_limit_exceeded notification {notification.id} for user {user_id}" f"Created insufficient_credits notification {notification.id} "
f"for user {user_id}"
) )
return notification return notification

View file

@ -0,0 +1,31 @@
"""Pure presentation logic for auto-reload-failure notifications."""
from __future__ import annotations
from datetime import UTC, datetime
def operation_id(payment_intent_id: str) -> str:
"""Build a unique id for an auto-reload-failure notification.
Keyed on the failed PaymentIntent so retries of the same charge collapse
into a single inbox item rather than spamming the user.
"""
if payment_intent_id:
return f"auto_reload_failed_{payment_intent_id}"
timestamp = datetime.now(UTC).strftime("%Y%m%d_%H%M%S_%f")
return f"auto_reload_failed_{timestamp}"
def summary(amount_micros: int, reason: str | None) -> tuple[str, str]:
"""Compute the title and message for a failed off-session auto-reload charge."""
amount_usd = max(0, amount_micros) / 1_000_000
title = "Auto-reload failed"
base = (
f"We couldn't automatically add ${amount_usd:.2f} of credit because your "
"saved card was declined. Auto-reload has been turned off — update your "
"card and re-enable it to keep topping up automatically."
)
if reason:
base = f"{base} (Reason: {reason}.)"
return title, base

View file

@ -0,0 +1,30 @@
"""Pure presentation logic for insufficient-credit notifications."""
from __future__ import annotations
import hashlib
from datetime import UTC, datetime
from app.notifications.service.messages.text import truncate
def operation_id(document_name: str, search_space_id: int) -> str:
"""Build a unique id for an insufficient-credits notification."""
timestamp = datetime.now(UTC).strftime("%Y%m%d_%H%M%S_%f")
doc_hash = hashlib.md5(document_name.encode()).hexdigest()[:8]
return f"insufficient_credits_{search_space_id}_{timestamp}_{doc_hash}"
def summary(
document_name: str, balance_micros: int, required_micros: int
) -> tuple[str, str]:
"""Compute the title and message for a blocked-by-insufficient-credits document."""
display_name = truncate(document_name, 40)
title = f"Insufficient credits: {display_name}"
balance_usd = max(0, balance_micros) / 1_000_000
required_usd = max(0, required_micros) / 1_000_000
message = (
f"This document costs about ${required_usd:.2f} to process but you have "
f"${balance_usd:.2f} of credit left. Add more credits to continue."
)
return title, message

View file

@ -1,25 +0,0 @@
"""Pure presentation logic for page-limit notifications."""
from __future__ import annotations
import hashlib
from datetime import UTC, datetime
from app.notifications.service.messages.text import truncate
def operation_id(document_name: str, search_space_id: int) -> str:
"""Build a unique id for a page-limit notification."""
timestamp = datetime.now(UTC).strftime("%Y%m%d_%H%M%S_%f")
doc_hash = hashlib.md5(document_name.encode()).hexdigest()[:8]
return f"page_limit_{search_space_id}_{timestamp}_{doc_hash}"
def summary(
document_name: str, pages_used: int, pages_limit: int, pages_to_add: int
) -> tuple[str, str]:
"""Compute the title and message for a blocked-by-page-limit document."""
display_name = truncate(document_name, 40)
title = f"Page limit exceeded: {display_name}"
message = f"This document has ~{pages_to_add} page(s) but you've used {pages_used}/{pages_limit} pages. Upgrade to process more documents."
return title, message

View file

@ -10,7 +10,8 @@ NotificationType = Literal[
"document_processing", "document_processing",
"new_mention", "new_mention",
"comment_reply", "comment_reply",
"page_limit_exceeded", "insufficient_credits",
"auto_reload_failed",
] ]
NotificationCategory = Literal["comments", "status"] NotificationCategory = Literal["comments", "status"]

View file

@ -622,11 +622,10 @@ async def create_image_generation(
detail={ detail={
"error_code": "premium_quota_exhausted", "error_code": "premium_quota_exhausted",
"usage_type": exc.usage_type, "usage_type": exc.usage_type,
"used_micros": exc.used_micros, "balance_micros": exc.balance_micros,
"limit_micros": exc.limit_micros,
"remaining_micros": exc.remaining_micros, "remaining_micros": exc.remaining_micros,
"message": ( "message": (
"Out of premium credits for image generation. " "Out of credits for image generation. "
"Purchase additional credits or switch to a free model." "Purchase additional credits or switch to a free model."
), ),
}, },

View file

@ -1,6 +1,6 @@
""" """
Incentive Tasks API routes. Incentive Tasks API routes.
Allows users to complete tasks (like starring GitHub repo) to earn free pages. Allows users to complete tasks (like starring GitHub repo) to earn free credits.
Each task can only be completed once per user. Each task can only be completed once per user.
""" """
@ -42,21 +42,21 @@ async def get_incentive_tasks(
# Build task list with completion status # Build task list with completion status
tasks = [] tasks = []
total_pages_earned = 0 total_credit_micros_earned = 0
for task_type, config in INCENTIVE_TASKS_CONFIG.items(): for task_type, config in INCENTIVE_TASKS_CONFIG.items():
completed_task = completed_tasks.get(task_type) completed_task = completed_tasks.get(task_type)
is_completed = completed_task is not None is_completed = completed_task is not None
if is_completed: if is_completed:
total_pages_earned += completed_task.pages_awarded total_credit_micros_earned += completed_task.credit_micros_awarded
tasks.append( tasks.append(
IncentiveTaskInfo( IncentiveTaskInfo(
task_type=task_type, task_type=task_type,
title=config["title"], title=config["title"],
description=config["description"], description=config["description"],
pages_reward=config["pages_reward"], credit_micros_reward=config["credit_micros_reward"],
action_url=config["action_url"], action_url=config["action_url"],
completed=is_completed, completed=is_completed,
completed_at=completed_task.completed_at if completed_task else None, completed_at=completed_task.completed_at if completed_task else None,
@ -65,7 +65,7 @@ async def get_incentive_tasks(
return IncentiveTasksResponse( return IncentiveTasksResponse(
tasks=tasks, tasks=tasks,
total_pages_earned=total_pages_earned, total_credit_micros_earned=total_credit_micros_earned,
) )
@ -79,10 +79,10 @@ async def complete_task(
session: AsyncSession = Depends(get_async_session), session: AsyncSession = Depends(get_async_session),
) -> CompleteTaskResponse | TaskAlreadyCompletedResponse: ) -> CompleteTaskResponse | TaskAlreadyCompletedResponse:
""" """
Mark an incentive task as completed and award pages to the user. Mark an incentive task as completed and award credit to the user.
Each task can only be completed once. If the task was already completed, Each task can only be completed once. If the task was already completed,
returns the existing completion information without awarding additional pages. returns the existing completion information without awarding additional credit.
""" """
# Validate task type exists in config # Validate task type exists in config
task_config = INCENTIVE_TASKS_CONFIG.get(task_type) task_config = INCENTIVE_TASKS_CONFIG.get(task_type)
@ -109,25 +109,23 @@ async def complete_task(
) )
# Create the task completion record # Create the task completion record
pages_reward = task_config["pages_reward"] credit_micros_reward = task_config["credit_micros_reward"]
new_task = UserIncentiveTask( new_task = UserIncentiveTask(
user_id=user.id, user_id=user.id,
task_type=task_type, task_type=task_type,
pages_awarded=pages_reward, credit_micros_awarded=credit_micros_reward,
) )
session.add(new_task) session.add(new_task)
# pages_used can exceed pages_limit when a document's final page count is # Add the reward directly to the user's spendable wallet balance.
# determined after processing. Base the new limit on the higher of the two user.credit_micros_balance = user.credit_micros_balance + credit_micros_reward
# so the rewarded pages are fully usable above the current high-water mark.
user.pages_limit = max(user.pages_used, user.pages_limit) + pages_reward
await session.commit() await session.commit()
await session.refresh(user) await session.refresh(user)
return CompleteTaskResponse( return CompleteTaskResponse(
success=True, success=True,
message=f"Task completed! You earned {pages_reward} pages.", message=f"Task completed! You earned ${credit_micros_reward / 1_000_000:.2f} of credit.",
pages_awarded=pages_reward, credit_micros_awarded=credit_micros_reward,
new_pages_limit=user.pages_limit, new_balance_micros=user.credit_micros_balance,
) )

File diff suppressed because it is too large Load diff

View file

@ -111,11 +111,13 @@ from .search_space import (
SearchSpaceWithStats, SearchSpaceWithStats,
) )
from .stripe import ( from .stripe import (
CreateCheckoutSessionRequest, CreateCreditCheckoutSessionRequest,
CreateCheckoutSessionResponse, CreateCreditCheckoutSessionResponse,
CreditPurchaseHistoryResponse,
CreditPurchaseRead,
CreditStripeStatusResponse,
PagePurchaseHistoryResponse, PagePurchaseHistoryResponse,
PagePurchaseRead, PagePurchaseRead,
StripeStatusResponse,
StripeWebhookResponse, StripeWebhookResponse,
) )
from .users import UserCreate, UserRead, UserUpdate from .users import UserCreate, UserRead, UserUpdate
@ -143,8 +145,11 @@ __all__ = [
"ChunkCreate", "ChunkCreate",
"ChunkRead", "ChunkRead",
"ChunkUpdate", "ChunkUpdate",
"CreateCheckoutSessionRequest", "CreateCreditCheckoutSessionRequest",
"CreateCheckoutSessionResponse", "CreateCreditCheckoutSessionResponse",
"CreditPurchaseHistoryResponse",
"CreditPurchaseRead",
"CreditStripeStatusResponse",
"DefaultSystemInstructionsResponse", "DefaultSystemInstructionsResponse",
# Document schemas # Document schemas
"DocumentBase", "DocumentBase",
@ -257,7 +262,6 @@ __all__ = [
"SearchSpaceRead", "SearchSpaceRead",
"SearchSpaceUpdate", "SearchSpaceUpdate",
"SearchSpaceWithStats", "SearchSpaceWithStats",
"StripeStatusResponse",
"StripeWebhookResponse", "StripeWebhookResponse",
"ThreadHistoryLoadResponse", "ThreadHistoryLoadResponse",
"ThreadListItem", "ThreadListItem",

View file

@ -15,7 +15,8 @@ class IncentiveTaskInfo(BaseModel):
task_type: IncentiveTaskType task_type: IncentiveTaskType
title: str title: str
description: str description: str
pages_reward: int # Credit reward in USD micro-units (1_000_000 == $1.00).
credit_micros_reward: int
action_url: str action_url: str
completed: bool completed: bool
completed_at: datetime | None = None completed_at: datetime | None = None
@ -25,7 +26,7 @@ class IncentiveTasksResponse(BaseModel):
"""Response containing all available incentive tasks with completion status.""" """Response containing all available incentive tasks with completion status."""
tasks: list[IncentiveTaskInfo] tasks: list[IncentiveTaskInfo]
total_pages_earned: int total_credit_micros_earned: int
class CompleteTaskRequest(BaseModel): class CompleteTaskRequest(BaseModel):
@ -39,8 +40,8 @@ class CompleteTaskResponse(BaseModel):
success: bool success: bool
message: str message: str
pages_awarded: int credit_micros_awarded: int
new_pages_limit: int new_balance_micros: int
class TaskAlreadyCompletedResponse(BaseModel): class TaskAlreadyCompletedResponse(BaseModel):

View file

@ -1,4 +1,4 @@
"""Schemas for Stripe-backed page purchases.""" """Schemas for Stripe-backed credit purchases."""
import uuid import uuid
from datetime import datetime from datetime import datetime
@ -8,27 +8,59 @@ from pydantic import BaseModel, ConfigDict, Field
from app.db import PagePurchaseStatus from app.db import PagePurchaseStatus
class CreateCheckoutSessionRequest(BaseModel): class CreateCreditCheckoutSessionRequest(BaseModel):
"""Request body for creating a page-purchase checkout session.""" """Request body for creating a credit-purchase checkout session."""
quantity: int = Field(ge=1, le=100) quantity: int = Field(ge=1, le=10_000)
search_space_id: int = Field(ge=1) search_space_id: int = Field(ge=1)
class CreateCheckoutSessionResponse(BaseModel): class CreateCreditCheckoutSessionResponse(BaseModel):
"""Response containing the Stripe-hosted checkout URL.""" """Response containing the Stripe-hosted checkout URL."""
checkout_url: str checkout_url: str
class StripeStatusResponse(BaseModel): class CreditPurchaseRead(BaseModel):
"""Response describing Stripe page-buying availability.""" """Serialized credit purchase record.
page_buying_enabled: bool ``credit_micros_granted`` is in micro-USD (1_000_000 = $1.00).
"""
id: uuid.UUID
stripe_checkout_session_id: str
stripe_payment_intent_id: str | None = None
quantity: int
credit_micros_granted: int
amount_total: int | None = None
currency: str | None = None
source: str = "checkout"
status: str
completed_at: datetime | None = None
created_at: datetime
model_config = ConfigDict(from_attributes=True)
class CreditPurchaseHistoryResponse(BaseModel):
"""Response containing the user's credit purchases."""
purchases: list[CreditPurchaseRead]
class CreditStripeStatusResponse(BaseModel):
"""Response describing credit-buying availability and current balance.
``credit_micros_balance`` is in micro-USD; the FE divides by 1_000_000
to display USD.
"""
credit_buying_enabled: bool
credit_micros_balance: int = 0
class PagePurchaseRead(BaseModel): class PagePurchaseRead(BaseModel):
"""Serialized page-purchase record for purchase history.""" """Serialized legacy page-purchase record (read-only history)."""
id: uuid.UUID id: uuid.UUID
stripe_checkout_session_id: str stripe_checkout_session_id: str
@ -45,11 +77,52 @@ class PagePurchaseRead(BaseModel):
class PagePurchaseHistoryResponse(BaseModel): class PagePurchaseHistoryResponse(BaseModel):
"""Response containing the authenticated user's page purchases.""" """Response containing the authenticated user's legacy page purchases."""
purchases: list[PagePurchaseRead] purchases: list[PagePurchaseRead]
class AutoReloadSettingsResponse(BaseModel):
"""Auto-reload configuration + saved-card state for the settings UI.
All ``*_micros`` fields are micro-USD (1_000_000 == $1.00). ``feature_enabled``
reflects the server-side ``AUTO_RELOAD_ENABLED`` flag; when it is false the
UI should hide / disable the auto-reload controls entirely.
"""
feature_enabled: bool
enabled: bool = False
threshold_micros: int | None = None
amount_micros: int | None = None
min_amount_micros: int
has_payment_method: bool = False
failed_at: datetime | None = None
class UpdateAutoReloadSettingsRequest(BaseModel):
"""Update auto-reload preferences.
Enabling requires a saved card (set up via /stripe/auto-reload/setup) plus a
positive threshold and an amount of at least ``AUTO_RELOAD_MIN_AMOUNT_MICROS``.
"""
enabled: bool
threshold_micros: int | None = Field(default=None, ge=0)
amount_micros: int | None = Field(default=None, ge=0)
class CreateAutoReloadSetupSessionRequest(BaseModel):
"""Request body for starting the save-a-card (SetupIntent) checkout."""
search_space_id: int = Field(ge=1)
class CreateAutoReloadSetupSessionResponse(BaseModel):
"""Response containing the Stripe-hosted setup (save-card) checkout URL."""
checkout_url: str
class StripeWebhookResponse(BaseModel): class StripeWebhookResponse(BaseModel):
"""Generic acknowledgement for Stripe webhook delivery.""" """Generic acknowledgement for Stripe webhook delivery."""
@ -66,64 +139,6 @@ class FinalizeCheckoutResponse(BaseModel):
endpoint until it sees ``completed`` or a final ``failed``. endpoint until it sees ``completed`` or a final ``failed``.
""" """
purchase_type: str # "page_packs" | "premium_tokens"
status: str # PagePurchaseStatus / PremiumTokenPurchaseStatus value
pages_limit: int | None = None
pages_used: int | None = None
pages_granted: int | None = None
premium_credit_micros_limit: int | None = None
premium_credit_micros_used: int | None = None
premium_credit_micros_granted: int | None = None
class CreateTokenCheckoutSessionRequest(BaseModel):
"""Request body for creating a premium token purchase checkout session."""
quantity: int = Field(ge=1, le=100)
search_space_id: int = Field(ge=1)
class CreateTokenCheckoutSessionResponse(BaseModel):
"""Response containing the Stripe-hosted checkout URL."""
checkout_url: str
class TokenPurchaseRead(BaseModel):
"""Serialized premium credit purchase record.
``credit_micros_granted`` is in micro-USD (1_000_000 = $1.00). The
schema name kept ``Token`` for API back-compat with pinned clients.
"""
id: uuid.UUID
stripe_checkout_session_id: str
stripe_payment_intent_id: str | None = None
quantity: int
credit_micros_granted: int
amount_total: int | None = None
currency: str | None = None
status: str status: str
completed_at: datetime | None = None credit_micros_balance: int = 0
created_at: datetime credit_micros_granted: int | None = None
model_config = ConfigDict(from_attributes=True)
class TokenPurchaseHistoryResponse(BaseModel):
"""Response containing the user's premium credit purchases."""
purchases: list[TokenPurchaseRead]
class TokenStripeStatusResponse(BaseModel):
"""Response describing premium-credit-buying availability and balance.
All ``premium_credit_micros_*`` fields are in micro-USD; the FE
divides by 1_000_000 to display USD.
"""
token_buying_enabled: bool
premium_credit_micros_used: int = 0
premium_credit_micros_limit: int = 0
premium_credit_micros_remaining: int = 0

View file

@ -4,8 +4,7 @@ from fastapi_users import schemas
class UserRead(schemas.BaseUser[uuid.UUID]): class UserRead(schemas.BaseUser[uuid.UUID]):
pages_limit: int credit_micros_balance: int
pages_used: int
display_name: str | None = None display_name: str | None = None
avatar_url: str | None = None avatar_url: str | None = None

View file

@ -268,7 +268,7 @@ async def _is_premium_eligible(
parsed = _to_uuid(user_id) parsed = _to_uuid(user_id)
if parsed is None: if parsed is None:
return False return False
usage = await TokenQuotaService.premium_get_usage(session, parsed) usage = await TokenQuotaService.credit_get_usage(session, parsed)
return bool(usage.allowed) return bool(usage.allowed)

View file

@ -0,0 +1,99 @@
"""Debit-triggered credit auto-reload.
``maybe_trigger_auto_reload`` is a cheap, best-effort pre-filter invoked after
every credit debit (ETL ``charge_credits`` and premium ``credit_finalize``).
When the wallet drops below the user's configured threshold it enqueues the
Celery task that performs the authoritative re-check and the off-session Stripe
charge. All real safety (row lock, cooldown, Stripe idempotency) lives in the
task this function only avoids enqueuing work that obviously isn't needed.
Everything here is gated behind ``config.AUTO_RELOAD_ENABLED``; when the flag is
off this module is inert.
"""
from __future__ import annotations
import logging
from datetime import UTC, datetime, timedelta
from sqlalchemy import select
from app.config import config
logger = logging.getLogger(__name__)
async def maybe_trigger_auto_reload(user_id: str) -> None:
"""Enqueue an auto-reload charge if the user's balance fell below threshold.
Best-effort: any failure is swallowed by the caller. Opens its own
short-lived session so it never interferes with the caller's transaction
(it always runs after the caller has already committed the debit).
"""
if not config.AUTO_RELOAD_ENABLED:
return
from app.db import CreditPurchase, CreditPurchaseStatus, User, async_session_maker
async with async_session_maker() as session:
user = (
(await session.execute(select(User).where(User.id == user_id)))
.unique()
.scalar_one_or_none()
)
if user is None or not user.auto_reload_enabled:
return
if not (user.stripe_customer_id and user.auto_reload_payment_method_id):
return
threshold = user.auto_reload_threshold_micros
amount = user.auto_reload_amount_micros
if not threshold or not amount:
return
available = user.credit_micros_balance - user.credit_micros_reserved
if available >= threshold:
return
# Cheap cooldown pre-check: skip if a recent auto-reload purchase exists
# or a recent attempt failed (avoids hammering a declined card).
cutoff = datetime.now(UTC) - timedelta(
minutes=max(config.AUTO_RELOAD_COOLDOWN_MINUTES, 0)
)
if user.auto_reload_failed_at and user.auto_reload_failed_at >= cutoff:
return
recent = (
await session.execute(
select(CreditPurchase.id)
.where(
CreditPurchase.user_id == user.id,
CreditPurchase.source == "auto_reload",
CreditPurchase.created_at >= cutoff,
CreditPurchase.status.in_(
[
CreditPurchaseStatus.PENDING,
CreditPurchaseStatus.COMPLETED,
]
),
)
.limit(1)
)
).first()
if recent is not None:
return
# Enqueue outside the session. The task re-checks everything with a row
# lock before charging, so a benign race here only costs a no-op task run.
try:
from app.tasks.celery_tasks.auto_reload_task import (
auto_reload_credits_task,
)
auto_reload_credits_task.delay(str(user_id))
except Exception:
logger.warning(
"Failed to enqueue auto_reload_credits task for user %s",
user_id,
exc_info=True,
)

View file

@ -69,8 +69,8 @@ BillableSessionFactory = Callable[[], AbstractAsyncContextManager[AsyncSession]]
class QuotaInsufficientError(Exception): class QuotaInsufficientError(Exception):
"""Raised when ``TokenQuotaService.premium_reserve`` denies a billable """Raised when ``TokenQuotaService.credit_reserve`` denies a billable
call because the user has exhausted their premium credit pool. call because the user has exhausted their credit wallet.
The route handler should catch this and return HTTP 402 Payment The route handler should catch this and return HTTP 402 Payment
Required (or the equivalent for the surface area). Outside of the HTTP Required (or the equivalent for the surface area). Outside of the HTTP
@ -83,17 +83,15 @@ class QuotaInsufficientError(Exception):
self, self,
*, *,
usage_type: str, usage_type: str,
used_micros: int, balance_micros: int,
limit_micros: int,
remaining_micros: int, remaining_micros: int,
) -> None: ) -> None:
self.usage_type = usage_type self.usage_type = usage_type
self.used_micros = used_micros self.balance_micros = balance_micros
self.limit_micros = limit_micros
self.remaining_micros = remaining_micros self.remaining_micros = remaining_micros
super().__init__( super().__init__(
f"Premium credit exhausted for {usage_type}: " f"Credit exhausted for {usage_type}: "
f"used={used_micros} limit={limit_micros} remaining={remaining_micros} (micro-USD)" f"balance={balance_micros} remaining={remaining_micros} (micro-USD)"
) )
@ -267,7 +265,7 @@ async def billable_call(
``TokenTrackingCallback`` populates the accumulator automatically. ``TokenTrackingCallback`` populates the accumulator automatically.
Raises: Raises:
QuotaInsufficientError: when premium and ``premium_reserve`` denies. QuotaInsufficientError: when premium and ``credit_reserve`` denies.
""" """
is_premium = billing_tier == "premium" is_premium = billing_tier == "premium"
session_factory = billable_session_factory or shielded_async_session session_factory = billable_session_factory or shielded_async_session
@ -310,7 +308,7 @@ async def billable_call(
request_id = str(uuid4()) request_id = str(uuid4())
async with session_factory() as quota_session: async with session_factory() as quota_session:
reserve_result = await TokenQuotaService.premium_reserve( reserve_result = await TokenQuotaService.credit_reserve(
db_session=quota_session, db_session=quota_session,
user_id=user_id, user_id=user_id,
request_id=request_id, request_id=request_id,
@ -320,18 +318,16 @@ async def billable_call(
if not reserve_result.allowed: if not reserve_result.allowed:
logger.info( logger.info(
"[billable_call] reserve DENIED user=%s usage_type=%s " "[billable_call] reserve DENIED user=%s usage_type=%s "
"reserve=%d used=%d limit=%d remaining=%d", "reserve=%d balance=%d remaining=%d",
user_id, user_id,
usage_type, usage_type,
reserve_micros, reserve_micros,
reserve_result.used, reserve_result.balance,
reserve_result.limit,
reserve_result.remaining, reserve_result.remaining,
) )
raise QuotaInsufficientError( raise QuotaInsufficientError(
usage_type=usage_type, usage_type=usage_type,
used_micros=reserve_result.used, balance_micros=reserve_result.balance,
limit_micros=reserve_result.limit,
remaining_micros=reserve_result.remaining, remaining_micros=reserve_result.remaining,
) )
@ -352,14 +348,14 @@ async def billable_call(
# BaseException so cancellation also releases. # BaseException so cancellation also releases.
try: try:
async with session_factory() as quota_session: async with session_factory() as quota_session:
await TokenQuotaService.premium_release( await TokenQuotaService.credit_release(
db_session=quota_session, db_session=quota_session,
user_id=user_id, user_id=user_id,
reserved_micros=reserve_micros, reserved_micros=reserve_micros,
) )
except Exception: except Exception:
logger.exception( logger.exception(
"[billable_call] premium_release failed for user=%s " "[billable_call] credit_release failed for user=%s "
"reserve_micros=%d (reservation will be GC'd by quota " "reserve_micros=%d (reservation will be GC'd by quota "
"reconciliation if/when implemented)", "reconciliation if/when implemented)",
user_id, user_id,
@ -380,7 +376,7 @@ async def billable_call(
thread_id, thread_id,
) )
async with session_factory() as quota_session: async with session_factory() as quota_session:
final_result = await TokenQuotaService.premium_finalize( final_result = await TokenQuotaService.credit_finalize(
db_session=quota_session, db_session=quota_session,
user_id=user_id, user_id=user_id,
request_id=request_id, request_id=request_id,
@ -389,26 +385,25 @@ async def billable_call(
) )
logger.info( logger.info(
"[billable_call] finalize user=%s usage_type=%s actual=%d " "[billable_call] finalize user=%s usage_type=%s actual=%d "
"reserved=%dused=%d/%d (remaining=%d)", "reserved=%dbalance=%d (remaining=%d)",
user_id, user_id,
usage_type, usage_type,
actual_micros, actual_micros,
reserve_micros, reserve_micros,
final_result.used, final_result.balance,
final_result.limit,
final_result.remaining, final_result.remaining,
) )
except Exception as finalize_exc: except Exception as finalize_exc:
# Last-ditch: if finalize itself fails, we must at least release # Last-ditch: if finalize itself fails, we must at least release
# so the reservation doesn't leak. # so the reservation doesn't leak.
logger.exception( logger.exception(
"[billable_call] premium_finalize failed for user=%s; " "[billable_call] credit_finalize failed for user=%s; "
"attempting release", "attempting release",
user_id, user_id,
) )
try: try:
async with session_factory() as quota_session: async with session_factory() as quota_session:
await TokenQuotaService.premium_release( await TokenQuotaService.credit_release(
db_session=quota_session, db_session=quota_session,
user_id=user_id, user_id=user_id,
reserved_micros=reserve_micros, reserved_micros=reserve_micros,
@ -465,7 +460,7 @@ async def _resolve_agent_billing_for_search_space(
so the same model bills for chat + downstream podcast/video. If the so the same model bills for chat + downstream podcast/video. If the
user is not premium-eligible, the pin service auto-restricts to free user is not premium-eligible, the pin service auto-restricts to free
deployments denial only happens later in deployments denial only happens later in
``billable_call.premium_reserve`` if the pin really is premium and ``billable_call.credit_reserve`` if the pin really is premium and
credit ran out mid-flow. credit ran out mid-flow.
* ``thread_id`` is None: fallback to ``("free", "auto")``. Forward-compat * ``thread_id`` is None: fallback to ``("free", "auto")``. Forward-compat
for any future direct-API path; today both Celery tasks always pass for any future direct-API path; today both Celery tasks always pass

View file

@ -1,5 +1,14 @@
""" """
Service for managing user page limits for ETL services. Service for charging the unified credit wallet for ETL document processing.
Replaces the legacy ``PageLimitService`` page-quota model. Page counts are
still estimated the same way; they are now converted to USD micro-credits
(``config.MICROS_PER_PAGE`` per page, times a per-mode multiplier) and debited
from ``user.credit_micros_balance``.
When ``config.ETL_CREDIT_BILLING_ENABLED`` is False (the default for
self-hosted / OSS installs) every check/charge is a no-op, preserving the prior
effectively-unlimited ETL behaviour.
""" """
import os import os
@ -8,141 +17,125 @@ from pathlib import Path, PurePosixPath
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.config import config
class PageLimitExceededError(Exception):
""" class InsufficientCreditsError(Exception):
Exception raised when a user exceeds their page processing limit. """Raised when a user lacks enough credit to process a document."""
"""
def __init__( def __init__(
self, self,
message: str = "Page limit exceeded. Please contact admin to increase limits for your account.", message: str = "Insufficient credits to process this document. "
pages_used: int = 0, "Add more credits to continue.",
pages_limit: int = 0, balance_micros: int = 0,
pages_to_add: int = 0, required_micros: int = 0,
): ):
self.pages_used = pages_used self.balance_micros = balance_micros
self.pages_limit = pages_limit self.required_micros = required_micros
self.pages_to_add = pages_to_add
super().__init__(message) super().__init__(message)
class PageLimitService: class EtlCreditService:
"""Service for checking and updating user page limits.""" """Checks and charges the credit wallet for ETL page processing."""
def __init__(self, session: AsyncSession): def __init__(self, session: AsyncSession):
self.session = session self.session = session
async def check_page_limit( @staticmethod
self, user_id: str, estimated_pages: int = 1 def billing_enabled() -> bool:
) -> tuple[bool, int, int]: return config.ETL_CREDIT_BILLING_ENABLED
@staticmethod
def pages_to_micros(pages: int, multiplier: int = 1) -> int:
"""Convert a (multiplied) page count to USD micro-credits."""
return int(pages) * int(multiplier) * config.MICROS_PER_PAGE
async def get_available_micros(self, user_id: str) -> int | None:
"""Return spendable credit in micro-USD (``balance - reserved``).
Returns ``None`` when ETL billing is disabled, which callers treat as
"unlimited" (no batch skipping, no blocking).
""" """
Check if user has enough pages remaining for processing. if not config.ETL_CREDIT_BILLING_ENABLED:
return None
Args:
user_id: The user's ID
estimated_pages: Estimated number of pages to be processed
Returns:
Tuple of (has_capacity, pages_used, pages_limit)
Raises:
PageLimitExceededError: If user would exceed their page limit
"""
from app.db import User from app.db import User
# Get user's current page usage
result = await self.session.execute( result = await self.session.execute(
select(User.pages_used, User.pages_limit).where(User.id == user_id) select(User.credit_micros_balance, User.credit_micros_reserved).where(
User.id == user_id
)
) )
row = result.first() row = result.first()
if not row: if not row:
raise ValueError(f"User with ID {user_id} not found") raise ValueError(f"User with ID {user_id} not found")
pages_used, pages_limit = row balance, reserved = row
return balance - reserved
# Check if adding estimated pages would exceed limit async def check_credits(
if pages_used + estimated_pages > pages_limit: self, user_id: str, estimated_pages: int = 1, multiplier: int = 1
raise PageLimitExceededError( ) -> None:
message=f"Processing this document would exceed your page limit. " """Raise :class:`InsufficientCreditsError` if the user can't afford to
f"Used: {pages_used}/{pages_limit} pages. " process ``estimated_pages`` (times ``multiplier``).
f"Document has approximately {estimated_pages} page(s). "
f"Please contact admin to increase limits for your account.", No-op when ETL billing is disabled.
pages_used=pages_used, """
pages_limit=pages_limit, if not config.ETL_CREDIT_BILLING_ENABLED:
pages_to_add=estimated_pages, return
required = self.pages_to_micros(estimated_pages, multiplier)
available = await self.get_available_micros(user_id)
if available is None:
return
if required > available:
raise InsufficientCreditsError(
message=(
"Processing this document would exceed your available "
f"credit. Available: ${available / 1_000_000:.2f}. "
f"This document costs about ${required / 1_000_000:.2f} "
f"({estimated_pages} page(s)). Add more credits to continue."
),
balance_micros=available,
required_micros=required,
) )
return True, pages_used, pages_limit async def charge_credits(
self, user_id: str, pages: int, multiplier: int = 1
) -> int | None:
"""Debit the credit wallet after successful processing.
async def update_page_usage( The balance may dip slightly negative when the actual page count
self, user_id: str, pages_to_add: int, allow_exceed: bool = False exceeds the pre-check estimate (the document is already processed),
) -> int: mirroring the prior ``allow_exceed=True`` semantics.
Returns the new balance in micros, or ``None`` when billing is disabled.
""" """
Update user's page usage after successful processing. if not config.ETL_CREDIT_BILLING_ENABLED:
return None
Args:
user_id: The user's ID
pages_to_add: Number of pages to add to usage
allow_exceed: If True, allows update even if it exceeds limit
(used when document was already processed after passing initial check)
Returns:
New total pages_used value
Raises:
PageLimitExceededError: If adding pages would exceed limit and allow_exceed is False
"""
from app.db import User from app.db import User
# Get user
result = await self.session.execute(select(User).where(User.id == user_id)) result = await self.session.execute(select(User).where(User.id == user_id))
user = result.unique().scalar_one_or_none() user = result.unique().scalar_one_or_none()
if not user: if not user:
raise ValueError(f"User with ID {user_id} not found") raise ValueError(f"User with ID {user_id} not found")
# Check if this would exceed limit (only if allow_exceed is False) cost = self.pages_to_micros(pages, multiplier)
new_usage = user.pages_used + pages_to_add user.credit_micros_balance -= cost
if not allow_exceed and new_usage > user.pages_limit:
raise PageLimitExceededError(
message=f"Cannot update page usage. Would exceed limit. "
f"Current: {user.pages_used}/{user.pages_limit}, "
f"Trying to add: {pages_to_add}",
pages_used=user.pages_used,
pages_limit=user.pages_limit,
pages_to_add=pages_to_add,
)
# Update usage
user.pages_used = new_usage
await self.session.commit() await self.session.commit()
await self.session.refresh(user) await self.session.refresh(user)
return user.pages_used # Best-effort: fire an auto-reload check if the balance dropped low.
try:
from app.services.auto_reload_service import maybe_trigger_auto_reload
async def get_page_usage(self, user_id: str) -> tuple[int, int]: await maybe_trigger_auto_reload(user_id)
""" except Exception:
Get user's current page usage and limit. pass
Args: return user.credit_micros_balance
user_id: The user's ID
Returns:
Tuple of (pages_used, pages_limit)
"""
from app.db import User
result = await self.session.execute(
select(User.pages_used, User.pages_limit).where(User.id == user_id)
)
row = result.first()
if not row:
raise ValueError(f"User with ID {user_id} not found")
return row
def estimate_pages_from_elements(self, elements: list) -> int: def estimate_pages_from_elements(self, elements: list) -> int:
""" """

View file

@ -99,7 +99,18 @@ class QuotaStatus(StrEnum):
class QuotaResult: class QuotaResult:
__slots__ = ("allowed", "limit", "remaining", "reserved", "status", "used") # ``used``/``limit`` are used by the anonymous (Redis) token path.
# ``balance``/``remaining``/``reserved`` are used by the credit (Postgres)
# path, all in USD micro-units. ``remaining`` == spendable (balance - reserved).
__slots__ = (
"allowed",
"balance",
"limit",
"remaining",
"reserved",
"status",
"used",
)
def __init__( def __init__(
self, self,
@ -109,6 +120,7 @@ class QuotaResult:
limit: int, limit: int,
reserved: int = 0, reserved: int = 0,
remaining: int = 0, remaining: int = 0,
balance: int = 0,
): ):
self.allowed = allowed self.allowed = allowed
self.status = status self.status = status
@ -116,6 +128,7 @@ class QuotaResult:
self.limit = limit self.limit = limit
self.reserved = reserved self.reserved = reserved
self.remaining = remaining self.remaining = remaining
self.balance = balance
def to_dict(self) -> dict[str, Any]: def to_dict(self) -> dict[str, Any]:
return { return {
@ -125,6 +138,7 @@ class QuotaResult:
"limit": self.limit, "limit": self.limit,
"reserved": self.reserved, "reserved": self.reserved,
"remaining": self.remaining, "remaining": self.remaining,
"balance": self.balance,
} }
@ -505,19 +519,33 @@ class TokenQuotaService:
# ------------------------------------------------------------------ # ------------------------------------------------------------------
@staticmethod @staticmethod
async def premium_reserve( def _credit_status(balance: int) -> QuotaStatus:
"""Map a spendable balance to OK / WARNING / BLOCKED.
There is no longer a fixed ceiling, so WARNING fires on a low absolute
balance (``config.CREDIT_LOW_BALANCE_WARNING_MICROS``) instead of a
percentage of a limit.
"""
if balance <= 0:
return QuotaStatus.BLOCKED
if balance < config.CREDIT_LOW_BALANCE_WARNING_MICROS:
return QuotaStatus.WARNING
return QuotaStatus.OK
@staticmethod
async def credit_reserve(
db_session: AsyncSession, db_session: AsyncSession,
user_id: Any, user_id: Any,
request_id: str, request_id: str,
reserve_micros: int, reserve_micros: int,
) -> QuotaResult: ) -> QuotaResult:
"""Reserve ``reserve_micros`` (USD micro-units) from the user's """Reserve ``reserve_micros`` (USD micro-units) from the user's credit
premium credit balance. wallet.
``QuotaResult.used``/``limit``/``reserved``/``remaining`` are ``QuotaResult.balance``/``reserved``/``remaining`` are in micro-USD on
all in micro-USD on this code path; callers (chat stream, this code path; callers (chat stream, credit-status route, FE display)
token-status route, FE display) convert to dollars by dividing convert to dollars by dividing by 1_000_000. ``remaining`` is the
by 1_000_000. spendable amount (``balance - reserved``).
""" """
from app.db import User from app.db import User
@ -538,48 +566,41 @@ class TokenQuotaService:
limit=0, limit=0,
) )
limit = user.premium_credit_micros_limit balance = user.credit_micros_balance
used = user.premium_credit_micros_used reserved = user.credit_micros_reserved
reserved = user.premium_credit_micros_reserved
effective = used + reserved + reserve_micros # Block when the new hold would exceed the spendable balance.
if effective > limit: if reserved + reserve_micros > balance:
remaining = max(0, limit - used - reserved) remaining = max(0, balance - reserved)
await db_session.rollback() await db_session.rollback()
return QuotaResult( return QuotaResult(
allowed=False, allowed=False,
status=QuotaStatus.BLOCKED, status=QuotaStatus.BLOCKED,
used=used, used=0,
limit=limit, limit=balance,
reserved=reserved, reserved=reserved,
remaining=remaining, remaining=remaining,
balance=balance,
) )
user.premium_credit_micros_reserved = reserved + reserve_micros user.credit_micros_reserved = reserved + reserve_micros
await db_session.commit() await db_session.commit()
new_reserved = reserved + reserve_micros new_reserved = reserved + reserve_micros
remaining = max(0, limit - used - new_reserved) remaining = max(0, balance - new_reserved)
warning_threshold = int(limit * 0.8)
if (used + new_reserved) >= limit:
status = QuotaStatus.BLOCKED
elif (used + new_reserved) >= warning_threshold:
status = QuotaStatus.WARNING
else:
status = QuotaStatus.OK
return QuotaResult( return QuotaResult(
allowed=True, allowed=True,
status=status, status=TokenQuotaService._credit_status(remaining),
used=used, used=0,
limit=limit, limit=balance,
reserved=new_reserved, reserved=new_reserved,
remaining=remaining, remaining=remaining,
balance=balance,
) )
@staticmethod @staticmethod
async def premium_finalize( async def credit_finalize(
db_session: AsyncSession, db_session: AsyncSession,
user_id: Any, user_id: Any,
request_id: str, request_id: str,
@ -587,7 +608,8 @@ class TokenQuotaService:
reserved_micros: int, reserved_micros: int,
) -> QuotaResult: ) -> QuotaResult:
"""Settle the reservation: release ``reserved_micros`` and debit """Settle the reservation: release ``reserved_micros`` and debit
``actual_micros`` (the LiteLLM-reported provider cost in micro-USD). ``actual_micros`` (the LiteLLM-reported provider cost in micro-USD)
from the balance.
""" """
from app.db import User from app.db import User
@ -605,44 +627,42 @@ class TokenQuotaService:
allowed=False, status=QuotaStatus.BLOCKED, used=0, limit=0 allowed=False, status=QuotaStatus.BLOCKED, used=0, limit=0
) )
user.premium_credit_micros_reserved = max( user.credit_micros_reserved = max(
0, user.premium_credit_micros_reserved - reserved_micros 0, user.credit_micros_reserved - reserved_micros
)
user.premium_credit_micros_used = (
user.premium_credit_micros_used + actual_micros
) )
user.credit_micros_balance = user.credit_micros_balance - actual_micros
await db_session.commit() await db_session.commit()
limit = user.premium_credit_micros_limit balance = user.credit_micros_balance
used = user.premium_credit_micros_used reserved = user.credit_micros_reserved
reserved = user.premium_credit_micros_reserved remaining = max(0, balance - reserved)
remaining = max(0, limit - used - reserved)
warning_threshold = int(limit * 0.8) # Best-effort auto-reload nudge after the debit settles.
if used >= limit: try:
status = QuotaStatus.BLOCKED from app.services.auto_reload_service import maybe_trigger_auto_reload
elif used >= warning_threshold:
status = QuotaStatus.WARNING await maybe_trigger_auto_reload(user_id)
else: except Exception:
status = QuotaStatus.OK pass
return QuotaResult( return QuotaResult(
allowed=True, allowed=True,
status=status, status=TokenQuotaService._credit_status(remaining),
used=used, used=0,
limit=limit, limit=balance,
reserved=reserved, reserved=reserved,
remaining=remaining, remaining=remaining,
balance=balance,
) )
@staticmethod @staticmethod
async def premium_release( async def credit_release(
db_session: AsyncSession, db_session: AsyncSession,
user_id: Any, user_id: Any,
reserved_micros: int, reserved_micros: int,
) -> None: ) -> None:
"""Release ``reserved_micros`` previously held by ``premium_reserve``. """Release ``reserved_micros`` previously held by ``credit_reserve``.
Used when a request fails before finalize (so the reservation Used when a request fails before finalize (so the reservation
doesn't leak credit). doesn't leak credit).
@ -659,13 +679,13 @@ class TokenQuotaService:
.scalar_one_or_none() .scalar_one_or_none()
) )
if user is not None: if user is not None:
user.premium_credit_micros_reserved = max( user.credit_micros_reserved = max(
0, user.premium_credit_micros_reserved - reserved_micros 0, user.credit_micros_reserved - reserved_micros
) )
await db_session.commit() await db_session.commit()
@staticmethod @staticmethod
async def premium_get_usage( async def credit_get_usage(
db_session: AsyncSession, db_session: AsyncSession,
user_id: Any, user_id: Any,
) -> QuotaResult: ) -> QuotaResult:
@ -681,24 +701,16 @@ class TokenQuotaService:
allowed=False, status=QuotaStatus.BLOCKED, used=0, limit=0 allowed=False, status=QuotaStatus.BLOCKED, used=0, limit=0
) )
limit = user.premium_credit_micros_limit balance = user.credit_micros_balance
used = user.premium_credit_micros_used reserved = user.credit_micros_reserved
reserved = user.premium_credit_micros_reserved remaining = max(0, balance - reserved)
remaining = max(0, limit - used - reserved)
warning_threshold = int(limit * 0.8)
if used >= limit:
status = QuotaStatus.BLOCKED
elif used >= warning_threshold:
status = QuotaStatus.WARNING
else:
status = QuotaStatus.OK
return QuotaResult( return QuotaResult(
allowed=used < limit, allowed=remaining > 0,
status=status, status=TokenQuotaService._credit_status(remaining),
used=used, used=0,
limit=limit, limit=balance,
reserved=reserved, reserved=reserved,
remaining=remaining, remaining=remaining,
balance=balance,
) )

View file

@ -0,0 +1,296 @@
"""Debit-triggered off-session credit auto-reload.
Enqueued (best-effort) by ``auto_reload_service.maybe_trigger_auto_reload``
after a credit debit drops the wallet below the user's threshold. This task is
the authoritative path: it re-checks eligibility under a row lock, enforces the
cooldown, then charges the saved card off-session via a Stripe PaymentIntent
(Stripe: charging a saved card off-session).
Idempotency comes from three layers:
- a per-attempt CreditPurchase row created PENDING before the charge,
- a Stripe idempotency key derived from that row id,
- the ``payment_intent.*`` webhook backstop in ``stripe_routes`` that only
transitions PENDING rows.
"""
from __future__ import annotations
import logging
import uuid
from datetime import UTC, datetime, timedelta
from sqlalchemy import select
from stripe import CardError, StripeClient, StripeError
from app.celery_app import celery_app
from app.config import config
from app.db import CreditPurchase, CreditPurchaseStatus, User
from app.notifications.service import NotificationService
from app.tasks.celery_tasks import get_celery_session_maker, run_async_celery_task
logger = logging.getLogger(__name__)
# 1_000_000 micro-USD == $1.00 == 100 cents, so cents = micros / 10_000.
_MICROS_PER_CENT = 10_000
def _get_stripe_client() -> StripeClient | None:
if not config.STRIPE_SECRET_KEY:
logger.warning("Auto-reload skipped because STRIPE_SECRET_KEY is not set.")
return None
return StripeClient(config.STRIPE_SECRET_KEY)
def _card_error_payment_intent_id(exc: CardError) -> str | None:
"""Pull the PaymentIntent id off a declined off-session charge.
Per Stripe's off-session guide the failed intent is on ``exc.error.payment_intent``,
which may be a StripeObject or a plain dict depending on the SDK path.
"""
err = getattr(exc, "error", None)
pi = getattr(err, "payment_intent", None) if err is not None else None
if pi is None:
return None
if isinstance(pi, dict):
return pi.get("id")
return getattr(pi, "id", None)
@celery_app.task(name="auto_reload_credits")
def auto_reload_credits_task(user_id: str):
"""Charge the user's saved card to top up credits when below threshold."""
return run_async_celery_task(_auto_reload_credits, user_id)
async def _auto_reload_credits(user_id: str) -> None:
if not config.AUTO_RELOAD_ENABLED:
return
stripe_client = _get_stripe_client()
if stripe_client is None:
return
cooldown = timedelta(minutes=max(config.AUTO_RELOAD_COOLDOWN_MINUTES, 0))
now = datetime.now(UTC)
cutoff = now - cooldown
async with get_celery_session_maker()() as db_session:
# Lock the user row so concurrent debits/tasks can't double-charge.
user = (
(
await db_session.execute(
select(User)
.where(User.id == uuid.UUID(user_id))
.with_for_update(of=User)
)
)
.unique()
.scalar_one_or_none()
)
if user is None or not user.auto_reload_enabled:
return
if not (user.stripe_customer_id and user.auto_reload_payment_method_id):
return
threshold = user.auto_reload_threshold_micros
amount = user.auto_reload_amount_micros
if not threshold or not amount:
return
available = user.credit_micros_balance - user.credit_micros_reserved
if available >= threshold:
# Another reload (or a refund/grant) already restored the balance.
return
# Cooldown: skip if a recent auto-reload purchase or failure happened.
recent = (
await db_session.execute(
select(CreditPurchase.id)
.where(
CreditPurchase.user_id == user.id,
CreditPurchase.source == "auto_reload",
CreditPurchase.created_at >= cutoff,
CreditPurchase.status.in_(
[
CreditPurchaseStatus.PENDING,
CreditPurchaseStatus.COMPLETED,
]
),
)
.limit(1)
)
).first()
if recent is not None:
return
if user.auto_reload_failed_at and user.auto_reload_failed_at >= cutoff:
return
customer_id = user.stripe_customer_id
payment_method_id = user.auto_reload_payment_method_id
amount_cents = max(round(amount / _MICROS_PER_CENT), 1)
# Create the PENDING purchase row first so its id seeds the Stripe
# idempotency key and the webhook backstop can find it.
purchase = CreditPurchase(
user_id=user.id,
stripe_checkout_session_id=f"auto_reload:{uuid.uuid4()}",
quantity=0,
credit_micros_granted=amount,
amount_total=amount_cents,
currency="usd",
source="auto_reload",
status=CreditPurchaseStatus.PENDING,
)
db_session.add(purchase)
await db_session.flush()
purchase_id = purchase.id
await db_session.commit()
# Charge off-session outside the user-row lock so the network call doesn't
# hold the row. The purchase row is the synchronization point now.
try:
payment_intent = stripe_client.v1.payment_intents.create(
params={
"amount": amount_cents,
"currency": "usd",
"customer": customer_id,
"payment_method": payment_method_id,
"off_session": True,
"confirm": True,
"metadata": {
"user_id": str(user_id),
"purchase_type": "auto_reload",
"purchase_id": str(purchase_id),
},
},
options={"idempotency_key": f"auto_reload:{purchase_id}"},
)
except CardError as exc:
await _record_failure(
purchase_id,
user_id,
amount,
payment_intent_id=_card_error_payment_intent_id(exc),
reason=getattr(exc, "user_message", None) or "Your card was declined.",
)
return
except StripeError:
logger.exception("Auto-reload charge failed for user %s", user_id)
await _record_failure(
purchase_id,
user_id,
amount,
payment_intent_id=None,
reason="We couldn't process the charge. Please try again.",
)
return
payment_intent_id = str(payment_intent.id)
pi_status = getattr(payment_intent, "status", None)
async with get_celery_session_maker()() as db_session:
purchase = (
await db_session.execute(
select(CreditPurchase)
.where(CreditPurchase.id == purchase_id)
.with_for_update()
)
).scalar_one_or_none()
if purchase is None:
return
purchase.stripe_payment_intent_id = payment_intent_id
if pi_status == "succeeded":
if purchase.status != CreditPurchaseStatus.COMPLETED:
user = (
(
await db_session.execute(
select(User)
.where(User.id == purchase.user_id)
.with_for_update(of=User)
)
)
.unique()
.scalar_one()
)
purchase.status = CreditPurchaseStatus.COMPLETED
purchase.completed_at = datetime.now(UTC)
user.credit_micros_balance = (
user.credit_micros_balance + purchase.credit_micros_granted
)
user.auto_reload_failed_at = None
await db_session.commit()
logger.info(
"Auto-reload succeeded for user %s (+%s micro-USD)",
user_id,
amount,
)
return
# Not succeeded synchronously (e.g. requires_action / processing).
# Leave the row PENDING; the payment_intent webhook reconciles it.
await db_session.commit()
logger.info(
"Auto-reload PaymentIntent %s for user %s is %s; awaiting webhook.",
payment_intent_id,
user_id,
pi_status,
)
async def _record_failure(
purchase_id: uuid.UUID,
user_id: str,
amount_micros: int,
*,
payment_intent_id: str | None,
reason: str | None,
) -> None:
"""Mark the purchase FAILED, stamp the user, and notify them."""
async with get_celery_session_maker()() as db_session:
purchase = (
await db_session.execute(
select(CreditPurchase)
.where(CreditPurchase.id == purchase_id)
.with_for_update()
)
).scalar_one_or_none()
if purchase is not None and purchase.status == CreditPurchaseStatus.PENDING:
purchase.status = CreditPurchaseStatus.FAILED
if payment_intent_id:
purchase.stripe_payment_intent_id = payment_intent_id
user = (
(
await db_session.execute(
select(User)
.where(User.id == uuid.UUID(user_id))
.with_for_update(of=User)
)
)
.unique()
.scalar_one_or_none()
)
if user is not None:
user.auto_reload_failed_at = datetime.now(UTC)
# Disable so a declined card doesn't get retried every debit; the
# user re-enables from settings (which clears the failure flag).
user.auto_reload_enabled = False
await db_session.commit()
try:
await NotificationService.auto_reload_failed.notify_auto_reload_failed(
session=db_session,
user_id=uuid.UUID(user_id),
amount_micros=amount_micros,
payment_intent_id=payment_intent_id,
reason=reason,
)
except Exception:
logger.warning(
"Failed to create auto_reload_failed notification for user %s",
user_id,
exc_info=True,
)

View file

@ -668,52 +668,52 @@ async def _process_file_upload(
# Import here to avoid circular dependencies # Import here to avoid circular dependencies
from fastapi import HTTPException from fastapi import HTTPException
from app.services.page_limit_service import PageLimitExceededError from app.services.etl_credit_service import InsufficientCreditsError
# Check if this is a page limit error (either direct or wrapped in HTTPException) # Check if this is an insufficient-credit error (either direct or
page_limit_error: PageLimitExceededError | None = None # wrapped in HTTPException)
if isinstance(e, PageLimitExceededError): credit_error: InsufficientCreditsError | None = None
page_limit_error = e if isinstance(e, InsufficientCreditsError):
credit_error = e
elif ( elif (
isinstance(e, HTTPException) isinstance(e, HTTPException)
and e.__cause__ and e.__cause__
and isinstance(e.__cause__, PageLimitExceededError) and isinstance(e.__cause__, InsufficientCreditsError)
): ):
# HTTPException wraps the original PageLimitExceededError # HTTPException wraps the original InsufficientCreditsError
page_limit_error = e.__cause__ credit_error = e.__cause__
elif isinstance(e, HTTPException) and "page limit" in str(e.detail).lower(): elif isinstance(e, HTTPException) and "credit" in str(e.detail).lower():
# Fallback: HTTPException with page limit message but no cause # Fallback: HTTPException with credit message but no cause
page_limit_error = None # We don't have the details credit_error = None # We don't have the details
# For page limit errors, create a dedicated page_limit_exceeded notification # For insufficient-credit errors, create a dedicated notification
if page_limit_error is not None: if credit_error is not None:
error_message = str(page_limit_error) error_message = str(credit_error)
# Create a dedicated page limit exceeded notification # Create a dedicated insufficient credits notification
try: try:
# First, mark the processing notification as failed # First, mark the processing notification as failed
await session.refresh(notification) await session.refresh(notification)
await NotificationService.document_processing.notify_processing_completed( await NotificationService.document_processing.notify_processing_completed(
session=session, session=session,
notification=notification, notification=notification,
error_message="Page limit exceeded", error_message="Insufficient credits",
) )
# Then create a separate page_limit_exceeded notification for better UX # Then create a separate insufficient_credits notification for better UX
await NotificationService.page_limit.notify_page_limit_exceeded( await NotificationService.insufficient_credits.notify_insufficient_credits(
session=session, session=session,
user_id=UUID(user_id), user_id=UUID(user_id),
document_name=filename, document_name=filename,
document_type="FILE", document_type="FILE",
search_space_id=search_space_id, search_space_id=search_space_id,
pages_used=page_limit_error.pages_used, balance_micros=credit_error.balance_micros,
pages_limit=page_limit_error.pages_limit, required_micros=credit_error.required_micros,
pages_to_add=page_limit_error.pages_to_add,
) )
except Exception as notif_error: except Exception as notif_error:
logger.error( logger.error(
f"Failed to create page limit notification: {notif_error!s}" f"Failed to create insufficient credits notification: {notif_error!s}"
) )
elif isinstance(e, HTTPException) and "page limit" in str(e.detail).lower(): elif isinstance(e, HTTPException) and "credit" in str(e.detail).lower():
# HTTPException with page limit message but no detailed cause # HTTPException with page limit message but no detailed cause
error_message = str(e.detail) error_message = str(e.detail)
try: try:
@ -984,18 +984,18 @@ async def _process_file_with_document(
# Import here to avoid circular dependencies # Import here to avoid circular dependencies
from fastapi import HTTPException from fastapi import HTTPException
from app.services.page_limit_service import PageLimitExceededError from app.services.etl_credit_service import InsufficientCreditsError
# Check if this is a page limit error # Check if this is an insufficient-credit error
page_limit_error: PageLimitExceededError | None = None credit_error: InsufficientCreditsError | None = None
if isinstance(e, PageLimitExceededError): if isinstance(e, InsufficientCreditsError):
page_limit_error = e credit_error = e
elif ( elif (
isinstance(e, HTTPException) isinstance(e, HTTPException)
and e.__cause__ and e.__cause__
and isinstance(e.__cause__, PageLimitExceededError) and isinstance(e.__cause__, InsufficientCreditsError)
): ):
page_limit_error = e.__cause__ credit_error = e.__cause__
# Mark document as failed (shows error in UI via Zero) # Mark document as failed (shows error in UI via Zero)
error_message = str(e)[:500] error_message = str(e)[:500]
@ -1006,28 +1006,27 @@ async def _process_file_with_document(
f"[_process_file_with_document] Document {document_id} marked as failed: {error_message[:100]}" f"[_process_file_with_document] Document {document_id} marked as failed: {error_message[:100]}"
) )
# Handle page limit errors with dedicated notification # Handle insufficient-credit errors with dedicated notification
if page_limit_error is not None: if credit_error is not None:
try: try:
await session.refresh(notification) await session.refresh(notification)
await NotificationService.document_processing.notify_processing_completed( await NotificationService.document_processing.notify_processing_completed(
session=session, session=session,
notification=notification, notification=notification,
error_message="Page limit exceeded", error_message="Insufficient credits",
) )
await NotificationService.page_limit.notify_page_limit_exceeded( await NotificationService.insufficient_credits.notify_insufficient_credits(
session=session, session=session,
user_id=UUID(user_id), user_id=UUID(user_id),
document_name=filename, document_name=filename,
document_type="FILE", document_type="FILE",
search_space_id=search_space_id, search_space_id=search_space_id,
pages_used=page_limit_error.pages_used, balance_micros=credit_error.balance_micros,
pages_limit=page_limit_error.pages_limit, required_micros=credit_error.required_micros,
pages_to_add=page_limit_error.pages_to_add,
) )
except Exception as notif_error: except Exception as notif_error:
logger.error( logger.error(
f"Failed to create page limit notification: {notif_error!s}" f"Failed to create insufficient credits notification: {notif_error!s}"
) )
else: else:
# Update notification on failure # Update notification on failure

View file

@ -164,11 +164,9 @@ async def _generate_content_podcast(
) )
except QuotaInsufficientError as exc: except QuotaInsufficientError as exc:
logger.info( logger.info(
"Podcast %s denied: out of premium credits " "Podcast %s denied: out of credits (balance=%d remaining=%d)",
"(used=%d/%d remaining=%d)",
podcast.id, podcast.id,
exc.used_micros, exc.balance_micros,
exc.limit_micros,
exc.remaining_micros, exc.remaining_micros,
) )
podcast.status = PodcastStatus.FAILED podcast.status = PodcastStatus.FAILED

View file

@ -1,4 +1,4 @@
"""Reconcile pending Stripe purchases that might miss webhook fulfillment.""" """Reconcile pending Stripe credit purchases that might miss webhook fulfillment."""
from __future__ import annotations from __future__ import annotations
@ -11,10 +11,8 @@ from stripe import StripeClient, StripeError
from app.celery_app import celery_app from app.celery_app import celery_app
from app.config import config from app.config import config
from app.db import ( from app.db import (
PagePurchase, CreditPurchase,
PagePurchaseStatus, CreditPurchaseStatus,
PremiumTokenPurchase,
PremiumTokenPurchaseStatus,
) )
from app.routes import stripe_routes from app.routes import stripe_routes
from app.tasks.celery_tasks import get_celery_session_maker, run_async_celery_task from app.tasks.celery_tasks import get_celery_session_maker, run_async_celery_task
@ -32,14 +30,14 @@ def get_stripe_client() -> StripeClient | None:
return StripeClient(config.STRIPE_SECRET_KEY) return StripeClient(config.STRIPE_SECRET_KEY)
@celery_app.task(name="reconcile_pending_stripe_page_purchases") @celery_app.task(name="reconcile_pending_stripe_credit_purchases")
def reconcile_pending_stripe_page_purchases_task(): def reconcile_pending_stripe_credit_purchases_task():
"""Recover paid purchases that were left pending due to missed webhook handling.""" """Recover paid credit purchases that were left pending due to missed webhook handling."""
return run_async_celery_task(_reconcile_pending_page_purchases) return run_async_celery_task(_reconcile_pending_credit_purchases)
async def _reconcile_pending_page_purchases() -> None: async def _reconcile_pending_credit_purchases() -> None:
"""Reconcile stale pending page purchases against Stripe source of truth. """Reconcile stale pending credit purchases against Stripe source of truth.
Stripe retries webhook delivery automatically, but best practice is to add an Stripe retries webhook delivery automatically, but best practice is to add an
application-level reconciliation path in case all retries fail or the endpoint application-level reconciliation path in case all retries fail or the endpoint
@ -57,12 +55,12 @@ async def _reconcile_pending_page_purchases() -> None:
pending_purchases = ( pending_purchases = (
( (
await db_session.execute( await db_session.execute(
select(PagePurchase) select(CreditPurchase)
.where( .where(
PagePurchase.status == PagePurchaseStatus.PENDING, CreditPurchase.status == CreditPurchaseStatus.PENDING,
PagePurchase.created_at <= cutoff, CreditPurchase.created_at <= cutoff,
) )
.order_by(PagePurchase.created_at.asc()) .order_by(CreditPurchase.created_at.asc())
.limit(batch_size) .limit(batch_size)
) )
) )
@ -72,13 +70,13 @@ async def _reconcile_pending_page_purchases() -> None:
if not pending_purchases: if not pending_purchases:
logger.debug( logger.debug(
"Stripe reconciliation found no pending purchases older than %s minutes.", "Stripe credit reconciliation found no pending purchases older than %s minutes.",
lookback_minutes, lookback_minutes,
) )
return return
logger.info( logger.info(
"Stripe reconciliation checking %s pending purchases (cutoff=%s, batch=%s).", "Stripe credit reconciliation checking %s pending purchases (cutoff=%s, batch=%s).",
len(pending_purchases), len(pending_purchases),
lookback_minutes, lookback_minutes,
batch_size, batch_size,
@ -96,7 +94,7 @@ async def _reconcile_pending_page_purchases() -> None:
) )
except StripeError: except StripeError:
logger.exception( logger.exception(
"Stripe reconciliation failed to retrieve checkout session %s", "Stripe credit reconciliation failed to retrieve checkout session %s",
checkout_session_id, checkout_session_id,
) )
await db_session.rollback() await db_session.rollback()
@ -107,119 +105,24 @@ async def _reconcile_pending_page_purchases() -> None:
try: try:
if payment_status in {"paid", "no_payment_required"}: if payment_status in {"paid", "no_payment_required"}:
await stripe_routes._fulfill_completed_purchase( await stripe_routes._fulfill_completed_credit_purchase(
db_session, checkout_session db_session, checkout_session
) )
fulfilled_count += 1 fulfilled_count += 1
elif session_status == "expired": elif session_status == "expired":
await stripe_routes._mark_purchase_failed( await stripe_routes._mark_credit_purchase_failed(
db_session, str(checkout_session.id) db_session, str(checkout_session.id)
) )
failed_count += 1 failed_count += 1
except Exception: except Exception:
logger.exception( logger.exception(
"Stripe reconciliation failed while processing checkout session %s", "Stripe credit reconciliation failed while processing checkout session %s",
checkout_session_id, checkout_session_id,
) )
await db_session.rollback() await db_session.rollback()
logger.info( logger.info(
"Stripe page reconciliation completed. fulfilled=%s failed=%s checked=%s", "Stripe credit reconciliation completed. fulfilled=%s failed=%s checked=%s",
fulfilled_count,
failed_count,
len(pending_purchases),
)
@celery_app.task(name="reconcile_pending_stripe_token_purchases")
def reconcile_pending_stripe_token_purchases_task():
"""Recover paid token purchases that were left pending due to missed webhook handling."""
return run_async_celery_task(_reconcile_pending_token_purchases)
async def _reconcile_pending_token_purchases() -> None:
"""Reconcile stale pending token purchases against Stripe source of truth."""
stripe_client = get_stripe_client()
if stripe_client is None:
return
lookback_minutes = max(config.STRIPE_RECONCILIATION_LOOKBACK_MINUTES, 0)
batch_size = max(config.STRIPE_RECONCILIATION_BATCH_SIZE, 1)
cutoff = datetime.now(UTC) - timedelta(minutes=lookback_minutes)
async with get_celery_session_maker()() as db_session:
pending_purchases = (
(
await db_session.execute(
select(PremiumTokenPurchase)
.where(
PremiumTokenPurchase.status
== PremiumTokenPurchaseStatus.PENDING,
PremiumTokenPurchase.created_at <= cutoff,
)
.order_by(PremiumTokenPurchase.created_at.asc())
.limit(batch_size)
)
)
.scalars()
.all()
)
if not pending_purchases:
logger.debug(
"Stripe token reconciliation found no pending purchases older than %s minutes.",
lookback_minutes,
)
return
logger.info(
"Stripe token reconciliation checking %s pending purchases (cutoff=%s, batch=%s).",
len(pending_purchases),
lookback_minutes,
batch_size,
)
fulfilled_count = 0
failed_count = 0
for purchase in pending_purchases:
checkout_session_id = purchase.stripe_checkout_session_id
try:
checkout_session = stripe_client.v1.checkout.sessions.retrieve(
checkout_session_id
)
except StripeError:
logger.exception(
"Stripe token reconciliation failed to retrieve checkout session %s",
checkout_session_id,
)
await db_session.rollback()
continue
payment_status = getattr(checkout_session, "payment_status", None)
session_status = getattr(checkout_session, "status", None)
try:
if payment_status in {"paid", "no_payment_required"}:
await stripe_routes._fulfill_completed_token_purchase(
db_session, checkout_session
)
fulfilled_count += 1
elif session_status == "expired":
await stripe_routes._mark_token_purchase_failed(
db_session, str(checkout_session.id)
)
failed_count += 1
except Exception:
logger.exception(
"Stripe token reconciliation failed while processing checkout session %s",
checkout_session_id,
)
await db_session.rollback()
logger.info(
"Stripe token reconciliation completed. fulfilled=%s failed=%s checked=%s",
fulfilled_count, fulfilled_count,
failed_count, failed_count,
len(pending_purchases), len(pending_purchases),

View file

@ -174,11 +174,10 @@ async def _generate_video_presentation(
) )
except QuotaInsufficientError as exc: except QuotaInsufficientError as exc:
logger.info( logger.info(
"VideoPresentation %s denied: out of premium credits " "VideoPresentation %s denied: out of credits "
"(used=%d/%d remaining=%d)", "(balance=%d remaining=%d)",
video_pres.id, video_pres.id,
exc.used_micros, exc.balance_micros,
exc.limit_micros,
exc.remaining_micros, exc.remaining_micros,
) )
video_pres.status = VideoPresentationStatus.FAILED video_pres.status = VideoPresentationStatus.FAILED

View file

@ -85,11 +85,11 @@ from app.tasks.chat.streaming.flows.shared.pre_stream_setup import (
setup_connector_and_firecrawl, setup_connector_and_firecrawl,
) )
from app.tasks.chat.streaming.flows.shared.premium_quota import ( from app.tasks.chat.streaming.flows.shared.premium_quota import (
PremiumReservation, CreditReservation,
finalize_premium, finalize_credit,
needs_premium_quota, needs_credit_quota,
release_premium, release_credit,
reserve_premium, reserve_credit,
) )
from app.tasks.chat.streaming.flows.shared.rate_limit_recovery import ( from app.tasks.chat.streaming.flows.shared.rate_limit_recovery import (
can_recover_provider_rate_limit, can_recover_provider_rate_limit,
@ -182,7 +182,7 @@ async def stream_new_chat(
accumulator = start_turn() accumulator = start_turn()
premium_reservation: PremiumReservation | None = None premium_reservation: CreditReservation | None = None
busy_error_raised = False busy_error_raised = False
emit_stream_error = partial( emit_stream_error = partial(
@ -259,8 +259,8 @@ async def stream_new_chat(
yield streaming_service.format_done() yield streaming_service.format_done()
return return
if needs_premium_quota(agent_config, user_id): if needs_credit_quota(agent_config, user_id):
premium_reservation = await reserve_premium( premium_reservation = await reserve_credit(
agent_config=agent_config, agent_config=agent_config,
user_id=user_id, # type: ignore[arg-type] user_id=user_id, # type: ignore[arg-type]
) )
@ -336,7 +336,7 @@ async def stream_new_chat(
else: else:
yield emit_stream_error( yield emit_stream_error(
message=( message=(
"Buy more tokens to continue with this model, or " "Buy more credits to continue with this model, or "
"switch to a free model" "switch to a free model"
), ),
error_kind="premium_quota_exhausted", error_kind="premium_quota_exhausted",
@ -762,7 +762,7 @@ async def stream_new_chat(
# sub-agent calls during a premium turn still contribute to the bill # sub-agent calls during a premium turn still contribute to the bill
# (they're $0 in practice anyway). # (they're $0 in practice anyway).
if premium_reservation is not None and user_id: if premium_reservation is not None and user_id:
await finalize_premium( await finalize_credit(
reservation=premium_reservation, reservation=premium_reservation,
user_id=user_id, user_id=user_id,
accumulator=accumulator, accumulator=accumulator,
@ -812,7 +812,7 @@ async def stream_new_chat(
end_turn(str(chat_id)) end_turn(str(chat_id))
if premium_reservation is not None and user_id: if premium_reservation is not None and user_id:
await release_premium(reservation=premium_reservation, user_id=user_id) await release_credit(reservation=premium_reservation, user_id=user_id)
await close_session_and_clear_ai_responding(session, chat_id) await close_session_and_clear_ai_responding(session, chat_id)

View file

@ -64,11 +64,11 @@ from app.tasks.chat.streaming.flows.shared.pre_stream_setup import (
setup_connector_and_firecrawl, setup_connector_and_firecrawl,
) )
from app.tasks.chat.streaming.flows.shared.premium_quota import ( from app.tasks.chat.streaming.flows.shared.premium_quota import (
PremiumReservation, CreditReservation,
finalize_premium, finalize_credit,
needs_premium_quota, needs_credit_quota,
release_premium, release_credit,
reserve_premium, reserve_credit,
) )
from app.tasks.chat.streaming.flows.shared.rate_limit_recovery import ( from app.tasks.chat.streaming.flows.shared.rate_limit_recovery import (
can_recover_provider_rate_limit, can_recover_provider_rate_limit,
@ -144,7 +144,7 @@ async def stream_resume_chat(
accumulator = start_turn() accumulator = start_turn()
premium_reservation: PremiumReservation | None = None premium_reservation: CreditReservation | None = None
busy_error_raised = False busy_error_raised = False
emit_stream_error = partial( emit_stream_error = partial(
@ -212,8 +212,8 @@ async def stream_resume_chat(
"[stream_resume] LLM config loaded in %.3fs", time.perf_counter() - _t0 "[stream_resume] LLM config loaded in %.3fs", time.perf_counter() - _t0
) )
if needs_premium_quota(agent_config, user_id): if needs_credit_quota(agent_config, user_id):
premium_reservation = await reserve_premium( premium_reservation = await reserve_credit(
agent_config=agent_config, agent_config=agent_config,
user_id=user_id, # type: ignore[arg-type] user_id=user_id, # type: ignore[arg-type]
) )
@ -285,7 +285,7 @@ async def stream_resume_chat(
else: else:
yield emit_stream_error( yield emit_stream_error(
message=( message=(
"Buy more tokens to continue with this model, or " "Buy more credits to continue with this model, or "
"switch to a free model" "switch to a free model"
), ),
error_kind="premium_quota_exhausted", error_kind="premium_quota_exhausted",
@ -544,7 +544,7 @@ async def stream_resume_chat(
return return
if premium_reservation is not None and user_id: if premium_reservation is not None and user_id:
await finalize_premium( await finalize_credit(
reservation=premium_reservation, reservation=premium_reservation,
user_id=user_id, user_id=user_id,
accumulator=accumulator, accumulator=accumulator,
@ -584,7 +584,7 @@ async def stream_resume_chat(
end_turn(str(chat_id)) end_turn(str(chat_id))
if premium_reservation is not None and user_id: if premium_reservation is not None and user_id:
await release_premium(reservation=premium_reservation, user_id=user_id) await release_credit(reservation=premium_reservation, user_id=user_id)
await close_session_and_clear_ai_responding(session, chat_id) await close_session_and_clear_ai_responding(session, chat_id)

View file

@ -1,13 +1,12 @@
"""Premium credit (USD micro-units) reserve / finalize / release lifecycle. """Credit wallet (USD micro-units) reserve / finalize / release lifecycle.
Both ``stream_new_chat`` and ``stream_resume_chat`` reserve premium credits up Both ``stream_new_chat`` and ``stream_resume_chat`` reserve credits up front (so
front (so a single LLM call can't run away with the budget), then finalize the a single LLM call can't run away with the budget), then finalize the actual
actual provider cost reported by LiteLLM when the turn completes successfully, provider cost reported by LiteLLM when the turn completes successfully, or
or release the reservation on the cancellation / interrupted-without-finalize release the reservation on the cancellation / interrupted-without-finalize paths.
paths.
State is held by the orchestrator as a simple ``PremiumReservation`` tuple State is held by the orchestrator as a simple ``CreditReservation`` so
so reservation, fallback-on-denied, finalize, and release can all be reasoned reservation, fallback-on-denied, finalize, and release can all be reasoned
about from one place. about from one place.
""" """
@ -27,8 +26,8 @@ if TYPE_CHECKING:
@dataclass @dataclass
class PremiumReservation: class CreditReservation:
"""Active premium-credit reservation for one turn. """Active credit reservation for one turn.
``request_id`` is the per-reservation idempotency key (also passed to ``request_id`` is the per-reservation idempotency key (also passed to
``finalize``/``release`` so racing branches resolve to the same row). ``finalize``/``release`` so racing branches resolve to the same row).
@ -41,15 +40,15 @@ class PremiumReservation:
allowed: bool allowed: bool
def needs_premium_quota(agent_config: AgentConfig | None, user_id: str | None) -> bool: def needs_credit_quota(agent_config: AgentConfig | None, user_id: str | None) -> bool:
return bool(agent_config is not None and user_id and agent_config.is_premium) return bool(agent_config is not None and user_id and agent_config.is_premium)
async def reserve_premium( async def reserve_credit(
*, *,
agent_config: AgentConfig, agent_config: AgentConfig,
user_id: str, user_id: str,
) -> PremiumReservation: ) -> CreditReservation:
"""Reserve estimated micros up front; returns the reservation handle.""" """Reserve estimated micros up front; returns the reservation handle."""
from app.services.token_quota_service import ( from app.services.token_quota_service import (
TokenQuotaService, TokenQuotaService,
@ -68,22 +67,22 @@ async def reserve_premium(
quota_reserve_tokens=agent_config.quota_reserve_tokens, quota_reserve_tokens=agent_config.quota_reserve_tokens,
) )
async with shielded_async_session() as quota_session: async with shielded_async_session() as quota_session:
quota_result = await TokenQuotaService.premium_reserve( quota_result = await TokenQuotaService.credit_reserve(
db_session=quota_session, db_session=quota_session,
user_id=UUID(user_id), user_id=UUID(user_id),
request_id=request_id, request_id=request_id,
reserve_micros=reserve_amount_micros, reserve_micros=reserve_amount_micros,
) )
return PremiumReservation( return CreditReservation(
request_id=request_id, request_id=request_id,
reserved_micros=reserve_amount_micros, reserved_micros=reserve_amount_micros,
allowed=quota_result.allowed, allowed=quota_result.allowed,
) )
async def finalize_premium( async def finalize_credit(
*, *,
reservation: PremiumReservation, reservation: CreditReservation,
user_id: str, user_id: str,
accumulator: TokenAccumulator, accumulator: TokenAccumulator,
) -> None: ) -> None:
@ -96,7 +95,7 @@ async def finalize_premium(
from app.services.token_quota_service import TokenQuotaService from app.services.token_quota_service import TokenQuotaService
async with shielded_async_session() as quota_session: async with shielded_async_session() as quota_session:
await TokenQuotaService.premium_finalize( await TokenQuotaService.credit_finalize(
db_session=quota_session, db_session=quota_session,
user_id=UUID(user_id), user_id=UUID(user_id),
request_id=reservation.request_id, request_id=reservation.request_id,
@ -105,15 +104,15 @@ async def finalize_premium(
) )
except Exception: except Exception:
logging.getLogger(__name__).warning( logging.getLogger(__name__).warning(
"Failed to finalize premium quota for user %s", "Failed to finalize credit quota for user %s",
user_id, user_id,
exc_info=True, exc_info=True,
) )
async def release_premium( async def release_credit(
*, *,
reservation: PremiumReservation, reservation: CreditReservation,
user_id: str, user_id: str,
) -> None: ) -> None:
"""Release the reservation on cancellation paths; never raises.""" """Release the reservation on cancellation paths; never raises."""
@ -121,12 +120,12 @@ async def release_premium(
from app.services.token_quota_service import TokenQuotaService from app.services.token_quota_service import TokenQuotaService
async with shielded_async_session() as quota_session: async with shielded_async_session() as quota_session:
await TokenQuotaService.premium_release( await TokenQuotaService.credit_release(
db_session=quota_session, db_session=quota_session,
user_id=UUID(user_id), user_id=UUID(user_id),
reserved_micros=reservation.reserved_micros, reserved_micros=reservation.reserved_micros,
) )
except Exception: except Exception:
logging.getLogger(__name__).warning( logging.getLogger(__name__).warning(
"Failed to release premium quota for user %s", user_id "Failed to release credit quota for user %s", user_id
) )

View file

@ -28,7 +28,7 @@ from app.indexing_pipeline.connector_document import ConnectorDocument
from app.indexing_pipeline.document_hashing import compute_identifier_hash from app.indexing_pipeline.document_hashing import compute_identifier_hash
from app.indexing_pipeline.exceptions import safe_exception_message from app.indexing_pipeline.exceptions import safe_exception_message
from app.indexing_pipeline.indexing_pipeline_service import IndexingPipelineService from app.indexing_pipeline.indexing_pipeline_service import IndexingPipelineService
from app.services.page_limit_service import PageLimitService from app.services.etl_credit_service import EtlCreditService
from app.services.task_logging_service import TaskLoggingService from app.services.task_logging_service import TaskLoggingService
from app.tasks.connector_indexers.base import ( from app.tasks.connector_indexers.base import (
check_document_by_unique_identifier, check_document_by_unique_identifier,
@ -423,9 +423,8 @@ async def _index_full_scan(
}, },
) )
page_limit_service = PageLimitService(session) etl_credit_service = EtlCreditService(session)
pages_used, pages_limit = await page_limit_service.get_page_usage(user_id) available_micros = await etl_credit_service.get_available_micros(user_id)
remaining_quota = pages_limit - pages_used
batch_estimated_pages = 0 batch_estimated_pages = 0
page_limit_reached = False page_limit_reached = False
@ -467,13 +466,17 @@ async def _index_full_scan(
skipped += 1 skipped += 1
continue continue
file_pages = PageLimitService.estimate_pages_from_metadata( file_pages = EtlCreditService.estimate_pages_from_metadata(
file.get("name", ""), file.get("size") file.get("name", ""), file.get("size")
) )
if batch_estimated_pages + file_pages > remaining_quota: if (
available_micros is not None
and EtlCreditService.pages_to_micros(batch_estimated_pages + file_pages)
> available_micros
):
if not page_limit_reached: if not page_limit_reached:
logger.warning( logger.warning(
"Page limit reached during Dropbox full scan, " "Insufficient credits during Dropbox full scan, "
"skipping remaining files" "skipping remaining files"
) )
page_limit_reached = True page_limit_reached = True
@ -498,9 +501,7 @@ async def _index_full_scan(
pages_to_deduct = max( pages_to_deduct = max(
1, batch_estimated_pages * batch_indexed // len(files_to_download) 1, batch_estimated_pages * batch_indexed // len(files_to_download)
) )
await page_limit_service.update_page_usage( await etl_credit_service.charge_credits(user_id, pages_to_deduct)
user_id, pages_to_deduct, allow_exceed=True
)
indexed = renamed_count + batch_indexed indexed = renamed_count + batch_indexed
logger.info( logger.info(
@ -523,9 +524,8 @@ async def _index_selected_files(
vision_llm=None, vision_llm=None,
) -> tuple[int, int, int, list[str]]: ) -> tuple[int, int, int, list[str]]:
"""Index user-selected files using the parallel pipeline.""" """Index user-selected files using the parallel pipeline."""
page_limit_service = PageLimitService(session) etl_credit_service = EtlCreditService(session)
pages_used, pages_limit = await page_limit_service.get_page_usage(user_id) available_micros = await etl_credit_service.get_available_micros(user_id)
remaining_quota = pages_limit - pages_used
batch_estimated_pages = 0 batch_estimated_pages = 0
files_to_download: list[dict] = [] files_to_download: list[dict] = []
@ -560,12 +560,16 @@ async def _index_selected_files(
skipped += 1 skipped += 1
continue continue
file_pages = PageLimitService.estimate_pages_from_metadata( file_pages = EtlCreditService.estimate_pages_from_metadata(
file.get("name", ""), file.get("size") file.get("name", ""), file.get("size")
) )
if batch_estimated_pages + file_pages > remaining_quota: if (
available_micros is not None
and EtlCreditService.pages_to_micros(batch_estimated_pages + file_pages)
> available_micros
):
display = file_name or file_path display = file_name or file_path
errors.append(f"File '{display}': page limit would be exceeded") errors.append(f"File '{display}': insufficient credits")
continue continue
batch_estimated_pages += file_pages batch_estimated_pages += file_pages
@ -586,9 +590,7 @@ async def _index_selected_files(
pages_to_deduct = max( pages_to_deduct = max(
1, batch_estimated_pages * batch_indexed // len(files_to_download) 1, batch_estimated_pages * batch_indexed // len(files_to_download)
) )
await page_limit_service.update_page_usage( await etl_credit_service.charge_credits(user_id, pages_to_deduct)
user_id, pages_to_deduct, allow_exceed=True
)
return renamed_count + batch_indexed, skipped, unsupported_count, errors return renamed_count + batch_indexed, skipped, unsupported_count, errors

View file

@ -41,7 +41,7 @@ from app.indexing_pipeline.indexing_pipeline_service import (
PlaceholderInfo, PlaceholderInfo,
) )
from app.services.composio_service import ComposioService from app.services.composio_service import ComposioService
from app.services.page_limit_service import PageLimitService from app.services.etl_credit_service import EtlCreditService
from app.services.task_logging_service import TaskLoggingService from app.services.task_logging_service import TaskLoggingService
from app.tasks.connector_indexers.base import ( from app.tasks.connector_indexers.base import (
check_document_by_unique_identifier, check_document_by_unique_identifier,
@ -555,11 +555,11 @@ async def _process_single_file(
return 1, 0, 0 return 1, 0, 0
return 0, 1, 0 return 0, 1, 0
page_limit_service = PageLimitService(session) etl_credit_service = EtlCreditService(session)
estimated_pages = PageLimitService.estimate_pages_from_metadata( estimated_pages = EtlCreditService.estimate_pages_from_metadata(
file_name, file.get("size") file_name, file.get("size")
) )
await page_limit_service.check_page_limit(user_id, estimated_pages) await etl_credit_service.check_credits(user_id, estimated_pages)
markdown, drive_metadata, error = await download_and_extract_content( markdown, drive_metadata, error = await download_and_extract_content(
drive_client, file, vision_llm=vision_llm drive_client, file, vision_llm=vision_llm
@ -602,9 +602,7 @@ async def _process_single_file(
continue continue
await pipeline.index(document, connector_doc) await pipeline.index(document, connector_doc)
await page_limit_service.update_page_usage( await etl_credit_service.charge_credits(user_id, estimated_pages)
user_id, estimated_pages, allow_exceed=True
)
logger.info(f"Successfully indexed Google Drive file: {file_name}") logger.info(f"Successfully indexed Google Drive file: {file_name}")
return 1, 0, 0 return 1, 0, 0
@ -713,9 +711,8 @@ async def _index_selected_files(
Returns (indexed_count, skipped_count, unsupported_count, errors). Returns (indexed_count, skipped_count, unsupported_count, errors).
""" """
page_limit_service = PageLimitService(session) etl_credit_service = EtlCreditService(session)
pages_used, pages_limit = await page_limit_service.get_page_usage(user_id) available_micros = await etl_credit_service.get_available_micros(user_id)
remaining_quota = pages_limit - pages_used
batch_estimated_pages = 0 batch_estimated_pages = 0
files_to_download: list[dict] = [] files_to_download: list[dict] = []
@ -741,12 +738,16 @@ async def _index_selected_files(
skipped += 1 skipped += 1
continue continue
file_pages = PageLimitService.estimate_pages_from_metadata( file_pages = EtlCreditService.estimate_pages_from_metadata(
file.get("name", ""), file.get("size") file.get("name", ""), file.get("size")
) )
if batch_estimated_pages + file_pages > remaining_quota: if (
available_micros is not None
and EtlCreditService.pages_to_micros(batch_estimated_pages + file_pages)
> available_micros
):
display = file_name or file_id display = file_name or file_id
errors.append(f"File '{display}': page limit would be exceeded") errors.append(f"File '{display}': insufficient credits")
continue continue
batch_estimated_pages += file_pages batch_estimated_pages += file_pages
@ -775,9 +776,7 @@ async def _index_selected_files(
pages_to_deduct = max( pages_to_deduct = max(
1, batch_estimated_pages * batch_indexed // len(files_to_download) 1, batch_estimated_pages * batch_indexed // len(files_to_download)
) )
await page_limit_service.update_page_usage( await etl_credit_service.charge_credits(user_id, pages_to_deduct)
user_id, pages_to_deduct, allow_exceed=True
)
return renamed_count + batch_indexed, skipped, unsupported_count, errors return renamed_count + batch_indexed, skipped, unsupported_count, errors
@ -820,9 +819,8 @@ async def _index_full_scan(
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# Phase 1 (serial): collect files, run skip checks, track renames # Phase 1 (serial): collect files, run skip checks, track renames
# ------------------------------------------------------------------ # ------------------------------------------------------------------
page_limit_service = PageLimitService(session) etl_credit_service = EtlCreditService(session)
pages_used, pages_limit = await page_limit_service.get_page_usage(user_id) available_micros = await etl_credit_service.get_available_micros(user_id)
remaining_quota = pages_limit - pages_used
batch_estimated_pages = 0 batch_estimated_pages = 0
page_limit_reached = False page_limit_reached = False
@ -877,13 +875,19 @@ async def _index_full_scan(
skipped += 1 skipped += 1
continue continue
file_pages = PageLimitService.estimate_pages_from_metadata( file_pages = EtlCreditService.estimate_pages_from_metadata(
file.get("name", ""), file.get("size") file.get("name", ""), file.get("size")
) )
if batch_estimated_pages + file_pages > remaining_quota: if (
available_micros is not None
and EtlCreditService.pages_to_micros(
batch_estimated_pages + file_pages
)
> available_micros
):
if not page_limit_reached: if not page_limit_reached:
logger.warning( logger.warning(
"Page limit reached during Google Drive full scan, " "Insufficient credits during Google Drive full scan, "
"skipping remaining files" "skipping remaining files"
) )
page_limit_reached = True page_limit_reached = True
@ -938,9 +942,7 @@ async def _index_full_scan(
pages_to_deduct = max( pages_to_deduct = max(
1, batch_estimated_pages * batch_indexed // len(files_to_download) 1, batch_estimated_pages * batch_indexed // len(files_to_download)
) )
await page_limit_service.update_page_usage( await etl_credit_service.charge_credits(user_id, pages_to_deduct)
user_id, pages_to_deduct, allow_exceed=True
)
indexed = renamed_count + batch_indexed indexed = renamed_count + batch_indexed
logger.info( logger.info(
@ -996,9 +998,8 @@ async def _index_with_delta_sync(
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# Phase 1 (serial): handle removals, collect files for download # Phase 1 (serial): handle removals, collect files for download
# ------------------------------------------------------------------ # ------------------------------------------------------------------
page_limit_service = PageLimitService(session) etl_credit_service = EtlCreditService(session)
pages_used, pages_limit = await page_limit_service.get_page_usage(user_id) available_micros = await etl_credit_service.get_available_micros(user_id)
remaining_quota = pages_limit - pages_used
batch_estimated_pages = 0 batch_estimated_pages = 0
page_limit_reached = False page_limit_reached = False
@ -1034,13 +1035,17 @@ async def _index_with_delta_sync(
skipped += 1 skipped += 1
continue continue
file_pages = PageLimitService.estimate_pages_from_metadata( file_pages = EtlCreditService.estimate_pages_from_metadata(
file.get("name", ""), file.get("size") file.get("name", ""), file.get("size")
) )
if batch_estimated_pages + file_pages > remaining_quota: if (
available_micros is not None
and EtlCreditService.pages_to_micros(batch_estimated_pages + file_pages)
> available_micros
):
if not page_limit_reached: if not page_limit_reached:
logger.warning( logger.warning(
"Page limit reached during Google Drive delta sync, " "Insufficient credits during Google Drive delta sync, "
"skipping remaining files" "skipping remaining files"
) )
page_limit_reached = True page_limit_reached = True
@ -1079,9 +1084,7 @@ async def _index_with_delta_sync(
pages_to_deduct = max( pages_to_deduct = max(
1, batch_estimated_pages * batch_indexed // len(files_to_download) 1, batch_estimated_pages * batch_indexed // len(files_to_download)
) )
await page_limit_service.update_page_usage( await etl_credit_service.charge_credits(user_id, pages_to_deduct)
user_id, pages_to_deduct, allow_exceed=True
)
indexed = renamed_count + batch_indexed indexed = renamed_count + batch_indexed
logger.info( logger.info(

View file

@ -33,7 +33,7 @@ from app.db import (
from app.indexing_pipeline.connector_document import ConnectorDocument from app.indexing_pipeline.connector_document import ConnectorDocument
from app.indexing_pipeline.document_hashing import compute_identifier_hash from app.indexing_pipeline.document_hashing import compute_identifier_hash
from app.indexing_pipeline.indexing_pipeline_service import IndexingPipelineService from app.indexing_pipeline.indexing_pipeline_service import IndexingPipelineService
from app.services.page_limit_service import PageLimitExceededError, PageLimitService from app.services.etl_credit_service import EtlCreditService, InsufficientCreditsError
from app.services.task_logging_service import TaskLoggingService from app.services.task_logging_service import TaskLoggingService
from app.tasks.celery_tasks import get_celery_session_maker from app.tasks.celery_tasks import get_celery_session_maker
from app.utils.document_versioning import create_version_snapshot from app.utils.document_versioning import create_version_snapshot
@ -46,38 +46,38 @@ from .base import (
HeartbeatCallbackType = Callable[[int], Awaitable[None]] HeartbeatCallbackType = Callable[[int], Awaitable[None]]
def _estimate_pages_safe(page_limit_service: PageLimitService, file_path: str) -> int: def _estimate_pages_safe(etl_credit_service: EtlCreditService, file_path: str) -> int:
"""Estimate page count with a file-size fallback.""" """Estimate page count with a file-size fallback."""
try: try:
return page_limit_service.estimate_pages_before_processing(file_path) return etl_credit_service.estimate_pages_before_processing(file_path)
except Exception: except Exception:
file_size = os.path.getsize(file_path) file_size = os.path.getsize(file_path)
return max(1, file_size // (80 * 1024)) return max(1, file_size // (80 * 1024))
async def _check_page_limit_or_skip( async def _check_credits_or_skip(
page_limit_service: PageLimitService, etl_credit_service: EtlCreditService,
user_id: str, user_id: str,
file_path: str, file_path: str,
page_multiplier: int = 1, page_multiplier: int = 1,
) -> tuple[int, int]: ) -> tuple[int, int]:
"""Estimate pages and check the limit; raises PageLimitExceededError if over quota. """Estimate pages and check credit; raises InsufficientCreditsError if unaffordable.
Returns (estimated_pages, billable_pages). Returns (estimated_pages, billable_pages).
""" """
estimated = _estimate_pages_safe(page_limit_service, file_path) estimated = _estimate_pages_safe(etl_credit_service, file_path)
billable = estimated * page_multiplier billable = estimated * page_multiplier
await page_limit_service.check_page_limit(user_id, billable) await etl_credit_service.check_credits(user_id, billable)
return estimated, billable return estimated, billable
def _compute_final_pages( def _compute_final_pages(
page_limit_service: PageLimitService, etl_credit_service: EtlCreditService,
estimated_pages: int, estimated_pages: int,
content_length: int, content_length: int,
) -> int: ) -> int:
"""Return the final page count as max(estimated, actual).""" """Return the final page count as max(estimated, actual)."""
actual = page_limit_service.estimate_pages_from_content_length(content_length) actual = etl_credit_service.estimate_pages_from_content_length(content_length)
return max(estimated_pages, actual) return max(estimated_pages, actual)
@ -635,7 +635,7 @@ async def index_local_folder(
skipped_count = 0 skipped_count = 0
failed_count = 0 failed_count = 0
page_limit_service = PageLimitService(session) etl_credit_service = EtlCreditService(session)
# ================================================================ # ================================================================
# PHASE 1: Pre-filter files (mtime / content-hash), version changed # PHASE 1: Pre-filter files (mtime / content-hash), version changed
@ -694,12 +694,12 @@ async def index_local_folder(
continue continue
try: try:
estimated_pages, _billable = await _check_page_limit_or_skip( estimated_pages, _billable = await _check_credits_or_skip(
page_limit_service, user_id, file_path_abs etl_credit_service, user_id, file_path_abs
) )
except PageLimitExceededError: except InsufficientCreditsError:
logger.warning( logger.warning(
f"Page limit exceeded, skipping: {file_path_abs}" f"Insufficient credits, skipping: {file_path_abs}"
) )
failed_count += 1 failed_count += 1
continue continue
@ -730,12 +730,12 @@ async def index_local_folder(
await create_version_snapshot(session, existing_document) await create_version_snapshot(session, existing_document)
else: else:
try: try:
estimated_pages, _billable = await _check_page_limit_or_skip( estimated_pages, _billable = await _check_credits_or_skip(
page_limit_service, user_id, file_path_abs etl_credit_service, user_id, file_path_abs
) )
except PageLimitExceededError: except InsufficientCreditsError:
logger.warning( logger.warning(
f"Page limit exceeded, skipping: {file_path_abs}" f"Insufficient credits, skipping: {file_path_abs}"
) )
failed_count += 1 failed_count += 1
continue continue
@ -858,11 +858,9 @@ async def index_local_folder(
est = mtime_info.get("estimated_pages", 1) est = mtime_info.get("estimated_pages", 1)
content_len = mtime_info.get("content_length", 0) content_len = mtime_info.get("content_length", 0)
final_pages = _compute_final_pages( final_pages = _compute_final_pages(
page_limit_service, est, content_len etl_credit_service, est, content_len
)
await page_limit_service.update_page_usage(
user_id, final_pages, allow_exceed=True
) )
await etl_credit_service.charge_credits(user_id, final_pages)
else: else:
failed_count += 1 failed_count += 1
@ -1072,13 +1070,13 @@ async def _index_single_file(
await session.commit() await session.commit()
return 0, 0, None return 0, 0, None
page_limit_service = PageLimitService(session) etl_credit_service = EtlCreditService(session)
try: try:
estimated_pages, _billable = await _check_page_limit_or_skip( estimated_pages, _billable = await _check_credits_or_skip(
page_limit_service, user_id, str(full_path) etl_credit_service, user_id, str(full_path)
) )
except PageLimitExceededError as e: except InsufficientCreditsError as e:
return 0, 1, f"Page limit exceeded: {e}" return 0, 1, f"Insufficient credits: {e}"
try: try:
content, content_hash = await _compute_file_content_hash( content, content_hash = await _compute_file_content_hash(
@ -1142,11 +1140,9 @@ async def _index_single_file(
if indexed: if indexed:
final_pages = _compute_final_pages( final_pages = _compute_final_pages(
page_limit_service, estimated_pages, len(content) etl_credit_service, estimated_pages, len(content)
)
await page_limit_service.update_page_usage(
user_id, final_pages, allow_exceed=True
) )
await etl_credit_service.charge_credits(user_id, final_pages)
await task_logger.log_task_success( await task_logger.log_task_success(
log_entry, log_entry,
f"Single file indexed: {rel_path}", f"Single file indexed: {rel_path}",
@ -1299,7 +1295,7 @@ async def index_uploaded_files(
await _set_indexing_flag(session, root_folder_id) await _set_indexing_flag(session, root_folder_id)
page_limit_service = PageLimitService(session) etl_credit_service = EtlCreditService(session)
pipeline = IndexingPipelineService(session) pipeline = IndexingPipelineService(session)
vision_llm_instance = None vision_llm_instance = None
@ -1345,14 +1341,14 @@ async def index_uploaded_files(
continue continue
try: try:
estimated_pages, _billable_pages = await _check_page_limit_or_skip( estimated_pages, _billable_pages = await _check_credits_or_skip(
page_limit_service, etl_credit_service,
user_id, user_id,
temp_path, temp_path,
page_multiplier=mode.page_multiplier, page_multiplier=mode.page_multiplier,
) )
except PageLimitExceededError: except InsufficientCreditsError:
logger.warning(f"Page limit exceeded, skipping: {relative_path}") logger.warning(f"Insufficient credits, skipping: {relative_path}")
failed_count += 1 failed_count += 1
continue continue
@ -1425,12 +1421,10 @@ async def index_uploaded_files(
if DocumentStatus.is_state(db_doc.status, DocumentStatus.READY): if DocumentStatus.is_state(db_doc.status, DocumentStatus.READY):
indexed_count += 1 indexed_count += 1
final_pages = _compute_final_pages( final_pages = _compute_final_pages(
page_limit_service, estimated_pages, len(content) etl_credit_service, estimated_pages, len(content)
) )
final_billable = final_pages * mode.page_multiplier final_billable = final_pages * mode.page_multiplier
await page_limit_service.update_page_usage( await etl_credit_service.charge_credits(user_id, final_billable)
user_id, final_billable, allow_exceed=True
)
else: else:
failed_count += 1 failed_count += 1

View file

@ -28,7 +28,7 @@ from app.indexing_pipeline.connector_document import ConnectorDocument
from app.indexing_pipeline.document_hashing import compute_identifier_hash from app.indexing_pipeline.document_hashing import compute_identifier_hash
from app.indexing_pipeline.exceptions import safe_exception_message from app.indexing_pipeline.exceptions import safe_exception_message
from app.indexing_pipeline.indexing_pipeline_service import IndexingPipelineService from app.indexing_pipeline.indexing_pipeline_service import IndexingPipelineService
from app.services.page_limit_service import PageLimitService from app.services.etl_credit_service import EtlCreditService
from app.services.task_logging_service import TaskLoggingService from app.services.task_logging_service import TaskLoggingService
from app.tasks.connector_indexers.base import ( from app.tasks.connector_indexers.base import (
check_document_by_unique_identifier, check_document_by_unique_identifier,
@ -318,9 +318,8 @@ async def _index_selected_files(
vision_llm=None, vision_llm=None,
) -> tuple[int, int, int, list[str]]: ) -> tuple[int, int, int, list[str]]:
"""Index user-selected files using the parallel pipeline.""" """Index user-selected files using the parallel pipeline."""
page_limit_service = PageLimitService(session) etl_credit_service = EtlCreditService(session)
pages_used, pages_limit = await page_limit_service.get_page_usage(user_id) available_micros = await etl_credit_service.get_available_micros(user_id)
remaining_quota = pages_limit - pages_used
batch_estimated_pages = 0 batch_estimated_pages = 0
files_to_download: list[dict] = [] files_to_download: list[dict] = []
@ -346,12 +345,16 @@ async def _index_selected_files(
skipped += 1 skipped += 1
continue continue
file_pages = PageLimitService.estimate_pages_from_metadata( file_pages = EtlCreditService.estimate_pages_from_metadata(
file.get("name", ""), file.get("size") file.get("name", ""), file.get("size")
) )
if batch_estimated_pages + file_pages > remaining_quota: if (
available_micros is not None
and EtlCreditService.pages_to_micros(batch_estimated_pages + file_pages)
> available_micros
):
display = file_name or file_id display = file_name or file_id
errors.append(f"File '{display}': page limit would be exceeded") errors.append(f"File '{display}': insufficient credits")
continue continue
batch_estimated_pages += file_pages batch_estimated_pages += file_pages
@ -372,9 +375,7 @@ async def _index_selected_files(
pages_to_deduct = max( pages_to_deduct = max(
1, batch_estimated_pages * batch_indexed // len(files_to_download) 1, batch_estimated_pages * batch_indexed // len(files_to_download)
) )
await page_limit_service.update_page_usage( await etl_credit_service.charge_credits(user_id, pages_to_deduct)
user_id, pages_to_deduct, allow_exceed=True
)
return renamed_count + batch_indexed, skipped, unsupported_count, errors return renamed_count + batch_indexed, skipped, unsupported_count, errors
@ -413,9 +414,8 @@ async def _index_full_scan(
}, },
) )
page_limit_service = PageLimitService(session) etl_credit_service = EtlCreditService(session)
pages_used, pages_limit = await page_limit_service.get_page_usage(user_id) available_micros = await etl_credit_service.get_available_micros(user_id)
remaining_quota = pages_limit - pages_used
batch_estimated_pages = 0 batch_estimated_pages = 0
page_limit_reached = False page_limit_reached = False
@ -448,13 +448,17 @@ async def _index_full_scan(
skipped += 1 skipped += 1
continue continue
file_pages = PageLimitService.estimate_pages_from_metadata( file_pages = EtlCreditService.estimate_pages_from_metadata(
file.get("name", ""), file.get("size") file.get("name", ""), file.get("size")
) )
if batch_estimated_pages + file_pages > remaining_quota: if (
available_micros is not None
and EtlCreditService.pages_to_micros(batch_estimated_pages + file_pages)
> available_micros
):
if not page_limit_reached: if not page_limit_reached:
logger.warning( logger.warning(
"Page limit reached during OneDrive full scan, " "Insufficient credits during OneDrive full scan, "
"skipping remaining files" "skipping remaining files"
) )
page_limit_reached = True page_limit_reached = True
@ -479,9 +483,7 @@ async def _index_full_scan(
pages_to_deduct = max( pages_to_deduct = max(
1, batch_estimated_pages * batch_indexed // len(files_to_download) 1, batch_estimated_pages * batch_indexed // len(files_to_download)
) )
await page_limit_service.update_page_usage( await etl_credit_service.charge_credits(user_id, pages_to_deduct)
user_id, pages_to_deduct, allow_exceed=True
)
indexed = renamed_count + batch_indexed indexed = renamed_count + batch_indexed
logger.info( logger.info(
@ -532,9 +534,8 @@ async def _index_with_delta_sync(
logger.info(f"Processing {len(changes)} delta changes") logger.info(f"Processing {len(changes)} delta changes")
page_limit_service = PageLimitService(session) etl_credit_service = EtlCreditService(session)
pages_used, pages_limit = await page_limit_service.get_page_usage(user_id) available_micros = await etl_credit_service.get_available_micros(user_id)
remaining_quota = pages_limit - pages_used
batch_estimated_pages = 0 batch_estimated_pages = 0
page_limit_reached = False page_limit_reached = False
@ -571,13 +572,17 @@ async def _index_with_delta_sync(
skipped += 1 skipped += 1
continue continue
file_pages = PageLimitService.estimate_pages_from_metadata( file_pages = EtlCreditService.estimate_pages_from_metadata(
change.get("name", ""), change.get("size") change.get("name", ""), change.get("size")
) )
if batch_estimated_pages + file_pages > remaining_quota: if (
available_micros is not None
and EtlCreditService.pages_to_micros(batch_estimated_pages + file_pages)
> available_micros
):
if not page_limit_reached: if not page_limit_reached:
logger.warning( logger.warning(
"Page limit reached during OneDrive delta sync, " "Insufficient credits during OneDrive delta sync, "
"skipping remaining files" "skipping remaining files"
) )
page_limit_reached = True page_limit_reached = True
@ -602,9 +607,7 @@ async def _index_with_delta_sync(
pages_to_deduct = max( pages_to_deduct = max(
1, batch_estimated_pages * batch_indexed // len(files_to_download) 1, batch_estimated_pages * batch_indexed // len(files_to_download)
) )
await page_limit_service.update_page_usage( await etl_credit_service.charge_credits(user_id, pages_to_deduct)
user_id, pages_to_deduct, allow_exceed=True
)
indexed = renamed_count + batch_indexed indexed = renamed_count + batch_indexed
logger.info( logger.info(

View file

@ -79,10 +79,10 @@ async def _notify(
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def _estimate_pages_safe(page_limit_service, file_path: str) -> int: def _estimate_pages_safe(etl_credit_service, file_path: str) -> int:
"""Estimate page count with a file-size fallback.""" """Estimate page count with a file-size fallback."""
try: try:
return page_limit_service.estimate_pages_before_processing(file_path) return etl_credit_service.estimate_pages_before_processing(file_path)
except Exception: except Exception:
file_size = os.path.getsize(file_path) file_size = os.path.getsize(file_path)
return max(1, file_size // (80 * 1024)) return max(1, file_size // (80 * 1024))
@ -185,11 +185,14 @@ async def _process_document_upload(ctx: _ProcessingContext) -> Document | None:
"""Route a document file to the configured ETL service via the unified pipeline.""" """Route a document file to the configured ETL service via the unified pipeline."""
from app.etl_pipeline.etl_document import EtlRequest, ProcessingMode from app.etl_pipeline.etl_document import EtlRequest, ProcessingMode
from app.etl_pipeline.etl_pipeline_service import EtlPipelineService from app.etl_pipeline.etl_pipeline_service import EtlPipelineService
from app.services.page_limit_service import PageLimitExceededError, PageLimitService from app.services.etl_credit_service import (
EtlCreditService,
InsufficientCreditsError,
)
mode = ProcessingMode.coerce(ctx.processing_mode) mode = ProcessingMode.coerce(ctx.processing_mode)
page_limit_service = PageLimitService(ctx.session) etl_credit_service = EtlCreditService(ctx.session)
estimated_pages = _estimate_pages_safe(page_limit_service, ctx.file_path) estimated_pages = _estimate_pages_safe(etl_credit_service, ctx.file_path)
billable_pages = estimated_pages * mode.page_multiplier billable_pages = estimated_pages * mode.page_multiplier
await ctx.task_logger.log_task_progress( await ctx.task_logger.log_task_progress(
@ -204,16 +207,16 @@ async def _process_document_upload(ctx: _ProcessingContext) -> Document | None:
) )
try: try:
await page_limit_service.check_page_limit(ctx.user_id, billable_pages) await etl_credit_service.check_credits(ctx.user_id, billable_pages)
except PageLimitExceededError as e: except InsufficientCreditsError as e:
await ctx.task_logger.log_task_failure( await ctx.task_logger.log_task_failure(
ctx.log_entry, ctx.log_entry,
f"Page limit exceeded before processing: {ctx.filename}", f"Insufficient credits before processing: {ctx.filename}",
str(e), str(e),
{ {
"error_type": "PageLimitExceeded", "error_type": "InsufficientCredits",
"pages_used": e.pages_used, "balance_micros": e.balance_micros,
"pages_limit": e.pages_limit, "required_micros": e.required_micros,
"estimated_pages": estimated_pages, "estimated_pages": estimated_pages,
"billable_pages": billable_pages, "billable_pages": billable_pages,
"processing_mode": mode.value, "processing_mode": mode.value,
@ -259,9 +262,7 @@ async def _process_document_upload(ctx: _ProcessingContext) -> Document | None:
) )
if result: if result:
await page_limit_service.update_page_usage( await etl_credit_service.charge_credits(ctx.user_id, billable_pages)
ctx.user_id, billable_pages, allow_exceed=True
)
if ctx.connector: if ctx.connector:
await update_document_from_connector(result, ctx.connector, ctx.session) await update_document_from_connector(result, ctx.connector, ctx.session)
await ctx.task_logger.log_task_success( await ctx.task_logger.log_task_success(
@ -337,11 +338,11 @@ async def process_file_in_background(
except Exception as e: except Exception as e:
await session.rollback() await session.rollback()
from app.services.page_limit_service import PageLimitExceededError from app.services.etl_credit_service import InsufficientCreditsError
if isinstance(e, PageLimitExceededError): if isinstance(e, InsufficientCreditsError):
error_message = str(e) error_message = str(e)
elif isinstance(e, HTTPException) and "page limit" in str(e.detail).lower(): elif isinstance(e, HTTPException) and "credit" in str(e.detail).lower():
error_message = str(e.detail) error_message = str(e.detail)
else: else:
error_message = f"Failed to process file: {filename}" error_message = f"Failed to process file: {filename}"
@ -414,12 +415,12 @@ async def _extract_file_content(
) )
if category == FileCategory.DOCUMENT: if category == FileCategory.DOCUMENT:
from app.services.page_limit_service import PageLimitService from app.services.etl_credit_service import EtlCreditService
page_limit_service = PageLimitService(session) etl_credit_service = EtlCreditService(session)
estimated_pages = _estimate_pages_safe(page_limit_service, file_path) estimated_pages = _estimate_pages_safe(etl_credit_service, file_path)
billable_pages = estimated_pages * mode.page_multiplier billable_pages = estimated_pages * mode.page_multiplier
await page_limit_service.check_page_limit(user_id, billable_pages) await etl_credit_service.check_credits(user_id, billable_pages)
# Vision LLM is provided to the ETL pipeline for any file category # Vision LLM is provided to the ETL pipeline for any file category
# when the operator opts in. Image files run through it directly; # when the operator opts in. Image files run through it directly;
@ -524,12 +525,10 @@ async def process_file_in_background_with_document(
) )
if billable_pages > 0: if billable_pages > 0:
from app.services.page_limit_service import PageLimitService from app.services.etl_credit_service import EtlCreditService
page_limit_service = PageLimitService(session) etl_credit_service = EtlCreditService(session)
await page_limit_service.update_page_usage( await etl_credit_service.charge_credits(user_id, billable_pages)
user_id, billable_pages, allow_exceed=True
)
await task_logger.log_task_success( await task_logger.log_task_success(
log_entry, log_entry,
@ -547,11 +546,11 @@ async def process_file_in_background_with_document(
except Exception as e: except Exception as e:
await session.rollback() await session.rollback()
from app.services.page_limit_service import PageLimitExceededError from app.services.etl_credit_service import InsufficientCreditsError
if isinstance(e, PageLimitExceededError): if isinstance(e, InsufficientCreditsError):
error_message = str(e) error_message = str(e)
elif isinstance(e, HTTPException) and "page limit" in str(e.detail).lower(): elif isinstance(e, HTTPException) and "credit" in str(e.detail).lower():
error_message = str(e.detail) error_message = str(e.detail)
else: else:
error_message = f"Failed to process file: {filename}" error_message = f"Failed to process file: {filename}"

View file

@ -38,10 +38,7 @@ DOCUMENT_COLS = [
USER_COLS = [ USER_COLS = [
"id", "id",
"pages_limit", "credit_micros_balance",
"pages_used",
"premium_credit_micros_limit",
"premium_credit_micros_used",
] ]
AUTOMATION_RUN_COLS = [ AUTOMATION_RUN_COLS = [

View file

@ -204,32 +204,34 @@ async def _cleanup_documents(
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Page-limit helpers (direct DB for setup, API for verification) # Credit-wallet helpers (direct DB for setup, API for verification)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
async def _get_user_page_usage(email: str) -> tuple[int, int]: async def _get_user_credit(email: str) -> tuple[int, int]:
conn = await asyncpg.connect(_ASYNCPG_URL) conn = await asyncpg.connect(_ASYNCPG_URL)
try: try:
row = await conn.fetchrow( row = await conn.fetchrow(
'SELECT pages_used, pages_limit FROM "user" WHERE email = $1', "SELECT credit_micros_balance, credit_micros_reserved "
'FROM "user" WHERE email = $1',
email, email,
) )
assert row is not None, f"User {email!r} not found in database" assert row is not None, f"User {email!r} not found in database"
return row["pages_used"], row["pages_limit"] return row["credit_micros_balance"], row["credit_micros_reserved"]
finally: finally:
await conn.close() await conn.close()
async def _set_user_page_limits( async def _set_user_credit(
email: str, *, pages_used: int, pages_limit: int email: str, *, balance_micros: int, reserved_micros: int = 0
) -> None: ) -> None:
conn = await asyncpg.connect(_ASYNCPG_URL) conn = await asyncpg.connect(_ASYNCPG_URL)
try: try:
await conn.execute( await conn.execute(
'UPDATE "user" SET pages_used = $1, pages_limit = $2 WHERE email = $3', 'UPDATE "user" SET credit_micros_balance = $1, '
pages_used, "credit_micros_reserved = $2 WHERE email = $3",
pages_limit, balance_micros,
reserved_micros,
email, email,
) )
finally: finally:
@ -237,23 +239,39 @@ async def _set_user_page_limits(
@pytest.fixture @pytest.fixture
async def page_limits(): async def credits():
"""Manipulate the test user's page limits (direct DB for setup only). """Manipulate the test user's credit wallet (direct DB for setup only).
Automatically restores original values after each test. Force-enables ETL credit billing for the duration of the test (it is off
by default for self-hosted/OSS, which would bypass all gating), and
automatically restores the original balance and billing flag afterwards.
``MICROS_PER_PAGE`` is exposed so callers can size balances by page count.
""" """
class _PageLimits: class _Credits:
async def set(self, *, pages_used: int, pages_limit: int) -> None: micros_per_page = app_config.MICROS_PER_PAGE
await _set_user_page_limits(
TEST_EMAIL, pages_used=pages_used, pages_limit=pages_limit async def set(self, *, balance_micros: int, reserved_micros: int = 0) -> None:
await _set_user_credit(
TEST_EMAIL,
balance_micros=balance_micros,
reserved_micros=reserved_micros,
) )
original = await _get_user_page_usage(TEST_EMAIL) def pages(self, n: int) -> int:
yield _PageLimits() return n * app_config.MICROS_PER_PAGE
await _set_user_page_limits(
TEST_EMAIL, pages_used=original[0], pages_limit=original[1] original_billing = app_config.ETL_CREDIT_BILLING_ENABLED
) app_config.ETL_CREDIT_BILLING_ENABLED = True
original = await _get_user_credit(TEST_EMAIL)
try:
yield _Credits()
finally:
app_config.ETL_CREDIT_BILLING_ENABLED = original_billing
await _set_user_credit(
TEST_EMAIL, balance_micros=original[0], reserved_micros=original[1]
)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------

View file

@ -1,14 +1,14 @@
""" """
Integration tests for page-limit enforcement during document upload. Integration tests for ETL credit enforcement during document upload.
These tests manipulate the test user's ``pages_used`` / ``pages_limit`` These tests manipulate the test user's ``credit_micros_balance`` column
columns directly in the database (setup only) and then exercise the upload directly in the database (setup only) and then exercise the upload pipeline
pipeline to verify that: to verify that:
- Uploads are rejected *before* ETL when the limit is exhausted. - Uploads are rejected *before* ETL when the wallet can't cover the cost.
- ``pages_used`` increases after a successful upload (verified via API). - The balance decreases after a successful upload (verified via API).
- A ``page_limit_exceeded`` notification is created on rejection. - An ``insufficient_credits`` notification is created on rejection.
- ``pages_used`` is not modified when a document fails processing. - The balance is not modified when a document fails processing.
All tests reuse the existing small fixtures (``sample.pdf``, ``sample.txt``) All tests reuse the existing small fixtures (``sample.pdf``, ``sample.txt``)
so no additional processing time is introduced. so no additional processing time is introduced.
@ -32,36 +32,37 @@ pytestmark = pytest.mark.integration
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Helper: read pages_used through the public API # Helper: read credit balance through the public API
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
async def _get_pages_used(client: httpx.AsyncClient, headers: dict[str, str]) -> int: async def _get_balance(client: httpx.AsyncClient, headers: dict[str, str]) -> int:
"""Fetch the current user's pages_used via the /users/me API.""" """Fetch the current user's credit_micros_balance via the /users/me API."""
resp = await client.get("/users/me", headers=headers) resp = await client.get("/users/me", headers=headers)
assert resp.status_code == 200, ( assert resp.status_code == 200, (
f"GET /users/me failed ({resp.status_code}): {resp.text}" f"GET /users/me failed ({resp.status_code}): {resp.text}"
) )
return resp.json()["pages_used"] return resp.json()["credit_micros_balance"]
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Test A: Successful upload increments pages_used # Test A: Successful upload decrements the balance
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class TestPageUsageIncrementsOnSuccess: class TestBalanceDecrementsOnSuccess:
"""After a successful PDF upload the user's ``pages_used`` must grow.""" """After a successful PDF upload the user's balance must shrink."""
async def test_pages_used_increases_after_pdf_upload( async def test_balance_decreases_after_pdf_upload(
self, self,
client: httpx.AsyncClient, client: httpx.AsyncClient,
headers: dict[str, str], headers: dict[str, str],
search_space_id: int, search_space_id: int,
cleanup_doc_ids: list[int], cleanup_doc_ids: list[int],
page_limits, credits,
): ):
await page_limits.set(pages_used=0, pages_limit=1000) await credits.set(balance_micros=credits.pages(1000))
before = await _get_balance(client, headers)
resp = await upload_file( resp = await upload_file(
client, headers, "sample.pdf", search_space_id=search_space_id client, headers, "sample.pdf", search_space_id=search_space_id
@ -76,30 +77,28 @@ class TestPageUsageIncrementsOnSuccess:
for did in doc_ids: for did in doc_ids:
assert statuses[did]["status"]["state"] == "ready" assert statuses[did]["status"]["state"] == "ready"
used = await _get_pages_used(client, headers) after = await _get_balance(client, headers)
assert used > 0, "pages_used should have increased after successful processing" assert after < before, "balance should have dropped after successful processing"
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Test B: Upload rejected when page limit is fully exhausted # Test B: Upload rejected when the wallet is empty
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class TestUploadRejectedWhenLimitExhausted: class TestUploadRejectedWhenCreditExhausted:
""" """When the balance is zero the document should reach ``failed`` status
When ``pages_used == pages_limit`` (zero remaining) the document with an insufficient-credit reason."""
should reach ``failed`` status with a page-limit reason.
"""
async def test_pdf_fails_when_no_pages_remaining( async def test_pdf_fails_when_no_credit_remaining(
self, self,
client: httpx.AsyncClient, client: httpx.AsyncClient,
headers: dict[str, str], headers: dict[str, str],
search_space_id: int, search_space_id: int,
cleanup_doc_ids: list[int], cleanup_doc_ids: list[int],
page_limits, credits,
): ):
await page_limits.set(pages_used=100, pages_limit=100) await credits.set(balance_micros=0)
resp = await upload_file( resp = await upload_file(
client, headers, "sample.pdf", search_space_id=search_space_id client, headers, "sample.pdf", search_space_id=search_space_id
@ -114,19 +113,19 @@ class TestUploadRejectedWhenLimitExhausted:
for did in doc_ids: for did in doc_ids:
assert statuses[did]["status"]["state"] == "failed" assert statuses[did]["status"]["state"] == "failed"
reason = statuses[did]["status"].get("reason", "").lower() reason = statuses[did]["status"].get("reason", "").lower()
assert "page limit" in reason, ( assert "credit" in reason, (
f"Expected 'page limit' in failure reason, got: {reason!r}" f"Expected 'credit' in failure reason, got: {reason!r}"
) )
async def test_pages_used_unchanged_after_limit_rejection( async def test_balance_unchanged_after_rejection(
self, self,
client: httpx.AsyncClient, client: httpx.AsyncClient,
headers: dict[str, str], headers: dict[str, str],
search_space_id: int, search_space_id: int,
cleanup_doc_ids: list[int], cleanup_doc_ids: list[int],
page_limits, credits,
): ):
await page_limits.set(pages_used=50, pages_limit=50) await credits.set(balance_micros=0)
resp = await upload_file( resp = await upload_file(
client, headers, "sample.pdf", search_space_id=search_space_id client, headers, "sample.pdf", search_space_id=search_space_id
@ -139,30 +138,30 @@ class TestUploadRejectedWhenLimitExhausted:
client, headers, doc_ids, search_space_id=search_space_id, timeout=300.0 client, headers, doc_ids, search_space_id=search_space_id, timeout=300.0
) )
used = await _get_pages_used(client, headers) balance = await _get_balance(client, headers)
assert used == 50, ( assert balance == 0, (
f"pages_used should remain 50 after rejected upload, got {used}" f"balance should remain 0 after rejected upload, got {balance}"
) )
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Test C: Page-limit notification is created on rejection # Test C: Insufficient-credits notification is created on rejection
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class TestPageLimitNotification: class TestInsufficientCreditsNotification:
"""A ``page_limit_exceeded`` notification must be created when upload """An ``insufficient_credits`` notification must be created when upload
is rejected due to the limit.""" is rejected due to an empty wallet."""
async def test_page_limit_exceeded_notification_created( async def test_insufficient_credits_notification_created(
self, self,
client: httpx.AsyncClient, client: httpx.AsyncClient,
headers: dict[str, str], headers: dict[str, str],
search_space_id: int, search_space_id: int,
cleanup_doc_ids: list[int], cleanup_doc_ids: list[int],
page_limits, credits,
): ):
await page_limits.set(pages_used=100, pages_limit=100) await credits.set(balance_micros=0)
resp = await upload_file( resp = await upload_file(
client, headers, "sample.pdf", search_space_id=search_space_id client, headers, "sample.pdf", search_space_id=search_space_id
@ -178,19 +177,18 @@ class TestPageLimitNotification:
notifications = await get_notifications( notifications = await get_notifications(
client, client,
headers, headers,
type_filter="page_limit_exceeded", type_filter="insufficient_credits",
search_space_id=search_space_id, search_space_id=search_space_id,
) )
assert len(notifications) >= 1, ( assert len(notifications) >= 1, (
"Expected at least one page_limit_exceeded notification" "Expected at least one insufficient_credits notification"
) )
latest = notifications[0] latest = notifications[0]
assert ( assert (
"page limit" in latest["title"].lower() "credit" in latest["title"].lower() or "credit" in latest["message"].lower()
or "page limit" in latest["message"].lower()
), ( ), (
f"Notification should mention page limit: title={latest['title']!r}, " f"Notification should mention credit: title={latest['title']!r}, "
f"message={latest['message']!r}" f"message={latest['message']!r}"
) )
@ -210,9 +208,9 @@ class TestDocumentProcessingNotification:
headers: dict[str, str], headers: dict[str, str],
search_space_id: int, search_space_id: int,
cleanup_doc_ids: list[int], cleanup_doc_ids: list[int],
page_limits, credits,
): ):
await page_limits.set(pages_used=0, pages_limit=1000) await credits.set(balance_micros=credits.pages(1000))
resp = await upload_file( resp = await upload_file(
client, headers, "sample.txt", search_space_id=search_space_id client, headers, "sample.txt", search_space_id=search_space_id
@ -242,23 +240,24 @@ class TestDocumentProcessingNotification:
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Test E: pages_used unchanged when a document fails for non-limit reasons # Test E: balance unchanged when a document fails for non-credit reasons
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class TestPagesUnchangedOnProcessingFailure: class TestBalanceUnchangedOnProcessingFailure:
"""If a document fails during ETL (e.g. empty/corrupt file) rather than """If a document fails during ETL (e.g. empty/corrupt file) rather than a
a page-limit rejection, ``pages_used`` should remain unchanged.""" credit rejection, the balance should remain unchanged."""
async def test_pages_used_stable_on_etl_failure( async def test_balance_stable_on_etl_failure(
self, self,
client: httpx.AsyncClient, client: httpx.AsyncClient,
headers: dict[str, str], headers: dict[str, str],
search_space_id: int, search_space_id: int,
cleanup_doc_ids: list[int], cleanup_doc_ids: list[int],
page_limits, credits,
): ):
await page_limits.set(pages_used=10, pages_limit=1000) starting = credits.pages(1000)
await credits.set(balance_micros=starting)
resp = await upload_file( resp = await upload_file(
client, headers, "empty.pdf", search_space_id=search_space_id client, headers, "empty.pdf", search_space_id=search_space_id
@ -274,28 +273,32 @@ class TestPagesUnchangedOnProcessingFailure:
for did in doc_ids: for did in doc_ids:
assert statuses[did]["status"]["state"] == "failed" assert statuses[did]["status"]["state"] == "failed"
used = await _get_pages_used(client, headers) balance = await _get_balance(client, headers)
assert used == 10, f"pages_used should remain 10 after ETL failure, got {used}" assert balance == starting, (
f"balance should remain {starting} after ETL failure, got {balance}"
)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Test F: Second upload rejected after first consumes remaining quota # Test F: Second upload rejected after first consumes remaining credit
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class TestSecondUploadExceedsLimit: class TestSecondUploadExceedsCredit:
"""Upload one PDF successfully, consuming the quota, then verify a """Upload one PDF successfully, consuming the credit, then verify a second
second upload is rejected.""" upload is rejected."""
async def test_second_upload_rejected_after_quota_consumed( async def test_second_upload_rejected_after_credit_consumed(
self, self,
client: httpx.AsyncClient, client: httpx.AsyncClient,
headers: dict[str, str], headers: dict[str, str],
search_space_id: int, search_space_id: int,
cleanup_doc_ids: list[int], cleanup_doc_ids: list[int],
page_limits, credits,
): ):
await page_limits.set(pages_used=0, pages_limit=1) # Exactly one page of credit: the first 1-page PDF fits, the second
# is rejected once the wallet hits zero.
await credits.set(balance_micros=credits.pages(1))
resp1 = await upload_file( resp1 = await upload_file(
client, headers, "sample.pdf", search_space_id=search_space_id client, headers, "sample.pdf", search_space_id=search_space_id
@ -327,6 +330,6 @@ class TestSecondUploadExceedsLimit:
for did in second_ids: for did in second_ids:
assert statuses2[did]["status"]["state"] == "failed" assert statuses2[did]["status"]["state"] == "failed"
reason = statuses2[did]["status"].get("reason", "").lower() reason = statuses2[did]["status"].get("reason", "").lower()
assert "page limit" in reason, ( assert "credit" in reason, (
f"Expected 'page limit' in failure reason, got: {reason!r}" f"Expected 'credit' in failure reason, got: {reason!r}"
) )

View file

@ -1,3 +1,10 @@
"""Integration tests for Stripe credit-pack purchases.
Buying credit packs tops up ``user.credit_micros_balance``. Legacy page-pack
buying has been removed; these tests exercise the credit checkout session,
webhook fulfillment (idempotent), and the reconciliation fallback.
"""
from __future__ import annotations from __future__ import annotations
from types import SimpleNamespace from types import SimpleNamespace
@ -19,6 +26,8 @@ pytestmark = pytest.mark.integration
_ASYNCPG_URL = TEST_DATABASE_URL.replace("postgresql+asyncpg://", "postgresql://") _ASYNCPG_URL = TEST_DATABASE_URL.replace("postgresql+asyncpg://", "postgresql://")
_CREDIT_MICROS_PER_UNIT = 1_000_000
async def _execute(query: str, *args) -> None: async def _execute(query: str, *args) -> None:
conn = await asyncpg.connect(_ASYNCPG_URL) conn = await asyncpg.connect(_ASYNCPG_URL)
@ -42,10 +51,12 @@ async def _get_user_id(email: str) -> str:
return str(row["id"]) return str(row["id"])
async def _get_pages_limit(email: str) -> int: async def _get_balance(email: str) -> int:
row = await _fetchrow('SELECT pages_limit FROM "user" WHERE email = $1', email) row = await _fetchrow(
'SELECT credit_micros_balance FROM "user" WHERE email = $1', email
)
assert row is not None, f"User {email!r} not found" assert row is not None, f"User {email!r} not found"
return row["pages_limit"] return row["credit_micros_balance"]
def _extract_access_token(response: httpx.Response) -> str | None: def _extract_access_token(response: httpx.Response) -> str | None:
@ -101,10 +112,23 @@ def headers(auth_token: str) -> dict[str, str]:
@pytest.fixture(autouse=True) @pytest.fixture(autouse=True)
async def _cleanup_page_purchases(): async def _cleanup_credit_purchases():
await _execute("DELETE FROM page_purchases") await _execute("DELETE FROM credit_purchases")
yield yield
await _execute("DELETE FROM page_purchases") await _execute("DELETE FROM credit_purchases")
def _configure_credit_buying(monkeypatch) -> None:
monkeypatch.setattr(stripe_routes.config, "STRIPE_CREDIT_BUYING_ENABLED", True)
monkeypatch.setattr(
stripe_routes.config, "STRIPE_CREDIT_PRICE_ID", "price_credit_1"
)
monkeypatch.setattr(
stripe_routes.config, "STRIPE_CREDIT_MICROS_PER_UNIT", _CREDIT_MICROS_PER_UNIT
)
monkeypatch.setattr(
stripe_routes.config, "NEXT_FRONTEND_URL", "http://localhost:3000"
)
class _FakeCreateStripeClient: class _FakeCreateStripeClient:
@ -152,18 +176,19 @@ class _FakeReconciliationStripeClient:
class TestStripeCheckoutSessionCreation: class TestStripeCheckoutSessionCreation:
async def test_get_status_reflects_backend_toggle( async def test_credit_status_reflects_backend_toggle(
self, client, headers, monkeypatch self, client, headers, monkeypatch
): ):
monkeypatch.setattr(stripe_routes.config, "STRIPE_PAGE_BUYING_ENABLED", False) monkeypatch.setattr(stripe_routes.config, "STRIPE_CREDIT_BUYING_ENABLED", False)
disabled_response = await client.get("/api/v1/stripe/status", headers=headers) disabled = await client.get("/api/v1/stripe/credit-status", headers=headers)
assert disabled_response.status_code == 200, disabled_response.text assert disabled.status_code == 200, disabled.text
assert disabled_response.json() == {"page_buying_enabled": False} assert disabled.json()["credit_buying_enabled"] is False
assert "credit_micros_balance" in disabled.json()
monkeypatch.setattr(stripe_routes.config, "STRIPE_PAGE_BUYING_ENABLED", True) monkeypatch.setattr(stripe_routes.config, "STRIPE_CREDIT_BUYING_ENABLED", True)
enabled_response = await client.get("/api/v1/stripe/status", headers=headers) enabled = await client.get("/api/v1/stripe/credit-status", headers=headers)
assert enabled_response.status_code == 200, enabled_response.text assert enabled.status_code == 200, enabled.text
assert enabled_response.json() == {"page_buying_enabled": True} assert enabled.json()["credit_buying_enabled"] is True
async def test_create_checkout_session_records_pending_purchase( async def test_create_checkout_session_records_pending_purchase(
self, self,
@ -182,14 +207,10 @@ class TestStripeCheckoutSessionCreation:
fake_client = _FakeCreateStripeClient(checkout_session) fake_client = _FakeCreateStripeClient(checkout_session)
monkeypatch.setattr(stripe_routes, "get_stripe_client", lambda: fake_client) monkeypatch.setattr(stripe_routes, "get_stripe_client", lambda: fake_client)
monkeypatch.setattr(stripe_routes.config, "STRIPE_PRICE_ID", "price_pages_1000") _configure_credit_buying(monkeypatch)
monkeypatch.setattr(
stripe_routes.config, "NEXT_FRONTEND_URL", "http://localhost:3000"
)
monkeypatch.setattr(stripe_routes.config, "STRIPE_PAGES_PER_UNIT", 1000)
response = await client.post( response = await client.post(
"/api/v1/stripe/create-checkout-session", "/api/v1/stripe/create-credit-checkout-session",
headers=headers, headers=headers,
json={"quantity": 2, "search_space_id": search_space_id}, json={"quantity": 2, "search_space_id": search_space_id},
) )
@ -199,7 +220,7 @@ class TestStripeCheckoutSessionCreation:
assert fake_client.last_params is not None assert fake_client.last_params is not None
assert fake_client.last_params["mode"] == "payment" assert fake_client.last_params["mode"] == "payment"
assert fake_client.last_params["line_items"] == [ assert fake_client.last_params["line_items"] == [
{"price": "price_pages_1000", "quantity": 2} {"price": "price_credit_1", "quantity": 2}
] ]
assert ( assert (
fake_client.last_params["success_url"] fake_client.last_params["success_url"]
@ -210,19 +231,21 @@ class TestStripeCheckoutSessionCreation:
fake_client.last_params["cancel_url"] fake_client.last_params["cancel_url"]
== f"http://localhost:3000/dashboard/{search_space_id}/purchase-cancel" == f"http://localhost:3000/dashboard/{search_space_id}/purchase-cancel"
) )
assert fake_client.last_params["metadata"]["purchase_type"] == "credits"
purchase = await _fetchrow( purchase = await _fetchrow(
""" """
SELECT quantity, pages_granted, status SELECT quantity, credit_micros_granted, status, source
FROM page_purchases FROM credit_purchases
WHERE stripe_checkout_session_id = $1 WHERE stripe_checkout_session_id = $1
""", """,
checkout_session.id, checkout_session.id,
) )
assert purchase is not None assert purchase is not None
assert purchase["quantity"] == 2 assert purchase["quantity"] == 2
assert purchase["pages_granted"] == 2000 assert purchase["credit_micros_granted"] == 2 * _CREDIT_MICROS_PER_UNIT
assert purchase["status"] == "PENDING" assert purchase["status"] == "PENDING"
assert purchase["source"] == "checkout"
async def test_create_checkout_session_returns_503_when_buying_disabled( async def test_create_checkout_session_returns_503_when_buying_disabled(
self, self,
@ -231,34 +254,34 @@ class TestStripeCheckoutSessionCreation:
search_space_id: int, search_space_id: int,
monkeypatch, monkeypatch,
): ):
monkeypatch.setattr(stripe_routes.config, "STRIPE_PAGE_BUYING_ENABLED", False) monkeypatch.setattr(stripe_routes.config, "STRIPE_CREDIT_BUYING_ENABLED", False)
response = await client.post( response = await client.post(
"/api/v1/stripe/create-checkout-session", "/api/v1/stripe/create-credit-checkout-session",
headers=headers, headers=headers,
json={"quantity": 2, "search_space_id": search_space_id}, json={"quantity": 2, "search_space_id": search_space_id},
) )
assert response.status_code == 503, response.text assert response.status_code == 503, response.text
assert ( assert (
response.json()["detail"] == "Page purchases are temporarily unavailable." response.json()["detail"] == "Credit purchases are temporarily unavailable."
) )
purchase_count = await _fetchrow("SELECT COUNT(*) AS count FROM page_purchases") count = await _fetchrow("SELECT COUNT(*) AS count FROM credit_purchases")
assert purchase_count is not None assert count is not None
assert purchase_count["count"] == 0 assert count["count"] == 0
class TestStripeWebhookFulfillment: class TestStripeWebhookFulfillment:
async def test_webhook_grants_pages_once( async def test_webhook_grants_credit_once(
self, self,
client, client,
headers, headers,
search_space_id: int, search_space_id: int,
page_limits, credits,
monkeypatch, monkeypatch,
): ):
await page_limits.set(pages_used=0, pages_limit=100) await credits.set(balance_micros=5_000_000)
checkout_session = SimpleNamespace( checkout_session = SimpleNamespace(
id="cs_test_webhook_123", id="cs_test_webhook_123",
@ -270,21 +293,16 @@ class TestStripeWebhookFulfillment:
create_client = _FakeCreateStripeClient(checkout_session) create_client = _FakeCreateStripeClient(checkout_session)
monkeypatch.setattr(stripe_routes, "get_stripe_client", lambda: create_client) monkeypatch.setattr(stripe_routes, "get_stripe_client", lambda: create_client)
monkeypatch.setattr(stripe_routes.config, "STRIPE_PRICE_ID", "price_pages_1000") _configure_credit_buying(monkeypatch)
monkeypatch.setattr(
stripe_routes.config, "NEXT_FRONTEND_URL", "http://localhost:3000"
)
monkeypatch.setattr(stripe_routes.config, "STRIPE_PAGES_PER_UNIT", 1000)
create_response = await client.post( create_response = await client.post(
"/api/v1/stripe/create-checkout-session", "/api/v1/stripe/create-credit-checkout-session",
headers=headers, headers=headers,
json={"quantity": 3, "search_space_id": search_space_id}, json={"quantity": 3, "search_space_id": search_space_id},
) )
assert create_response.status_code == 200, create_response.text assert create_response.status_code == 200, create_response.text
initial_limit = await _get_pages_limit(TEST_EMAIL) assert await _get_balance(TEST_EMAIL) == 5_000_000
assert initial_limit == 100
user_id = await _get_user_id(TEST_EMAIL) user_id = await _get_user_id(TEST_EMAIL)
webhook_checkout_session = SimpleNamespace( webhook_checkout_session = SimpleNamespace(
@ -296,7 +314,8 @@ class TestStripeWebhookFulfillment:
metadata={ metadata={
"user_id": user_id, "user_id": user_id,
"quantity": "3", "quantity": "3",
"pages_per_unit": "1000", "credit_micros_per_unit": str(_CREDIT_MICROS_PER_UNIT),
"purchase_type": "credits",
}, },
) )
event = SimpleNamespace( event = SimpleNamespace(
@ -315,13 +334,12 @@ class TestStripeWebhookFulfillment:
) )
assert first_response.status_code == 200, first_response.text assert first_response.status_code == 200, first_response.text
updated_limit = await _get_pages_limit(TEST_EMAIL) assert await _get_balance(TEST_EMAIL) == 5_000_000 + 3 * _CREDIT_MICROS_PER_UNIT
assert updated_limit == 3100
purchase = await _fetchrow( purchase = await _fetchrow(
""" """
SELECT status, amount_total, currency, stripe_payment_intent_id SELECT status, amount_total, currency, stripe_payment_intent_id
FROM page_purchases FROM credit_purchases
WHERE stripe_checkout_session_id = $1 WHERE stripe_checkout_session_id = $1
""", """,
checkout_session.id, checkout_session.id,
@ -339,7 +357,8 @@ class TestStripeWebhookFulfillment:
) )
assert second_response.status_code == 200, second_response.text assert second_response.status_code == 200, second_response.text
assert await _get_pages_limit(TEST_EMAIL) == 3100 # Idempotent: a duplicate webhook does not double-grant.
assert await _get_balance(TEST_EMAIL) == 5_000_000 + 3 * _CREDIT_MICROS_PER_UNIT
class TestStripeReconciliation: class TestStripeReconciliation:
@ -348,10 +367,10 @@ class TestStripeReconciliation:
client, client,
headers, headers,
search_space_id: int, search_space_id: int,
page_limits, credits,
monkeypatch, monkeypatch,
): ):
await page_limits.set(pages_used=220, pages_limit=150) await credits.set(balance_micros=1_000_000)
checkout_session = SimpleNamespace( checkout_session = SimpleNamespace(
id="cs_test_reconcile_paid_123", id="cs_test_reconcile_paid_123",
@ -363,19 +382,15 @@ class TestStripeReconciliation:
create_client = _FakeCreateStripeClient(checkout_session) create_client = _FakeCreateStripeClient(checkout_session)
monkeypatch.setattr(stripe_routes, "get_stripe_client", lambda: create_client) monkeypatch.setattr(stripe_routes, "get_stripe_client", lambda: create_client)
monkeypatch.setattr(stripe_routes.config, "STRIPE_PRICE_ID", "price_pages_1000") _configure_credit_buying(monkeypatch)
monkeypatch.setattr(
stripe_routes.config, "NEXT_FRONTEND_URL", "http://localhost:3000"
)
monkeypatch.setattr(stripe_routes.config, "STRIPE_PAGES_PER_UNIT", 1000)
create_response = await client.post( create_response = await client.post(
"/api/v1/stripe/create-checkout-session", "/api/v1/stripe/create-credit-checkout-session",
headers=headers, headers=headers,
json={"quantity": 3, "search_space_id": search_space_id}, json={"quantity": 3, "search_space_id": search_space_id},
) )
assert create_response.status_code == 200, create_response.text assert create_response.status_code == 200, create_response.text
assert await _get_pages_limit(TEST_EMAIL) == 150 assert await _get_balance(TEST_EMAIL) == 1_000_000
reconciled_session = SimpleNamespace( reconciled_session = SimpleNamespace(
id=checkout_session.id, id=checkout_session.id,
@ -402,15 +417,15 @@ class TestStripeReconciliation:
20, 20,
) )
await stripe_reconciliation_task._reconcile_pending_page_purchases() await stripe_reconciliation_task._reconcile_pending_credit_purchases()
assert reconcile_client.requested_ids == [checkout_session.id] assert reconcile_client.requested_ids == [checkout_session.id]
assert await _get_pages_limit(TEST_EMAIL) == 3220 assert await _get_balance(TEST_EMAIL) == 1_000_000 + 3 * _CREDIT_MICROS_PER_UNIT
purchase = await _fetchrow( purchase = await _fetchrow(
""" """
SELECT status, amount_total, currency, stripe_payment_intent_id SELECT status, amount_total, currency, stripe_payment_intent_id
FROM page_purchases FROM credit_purchases
WHERE stripe_checkout_session_id = $1 WHERE stripe_checkout_session_id = $1
""", """,
checkout_session.id, checkout_session.id,
@ -426,10 +441,10 @@ class TestStripeReconciliation:
client, client,
headers, headers,
search_space_id: int, search_space_id: int,
page_limits, credits,
monkeypatch, monkeypatch,
): ):
await page_limits.set(pages_used=0, pages_limit=500) await credits.set(balance_micros=500_000)
checkout_session = SimpleNamespace( checkout_session = SimpleNamespace(
id="cs_test_reconcile_expired_123", id="cs_test_reconcile_expired_123",
@ -441,14 +456,10 @@ class TestStripeReconciliation:
create_client = _FakeCreateStripeClient(checkout_session) create_client = _FakeCreateStripeClient(checkout_session)
monkeypatch.setattr(stripe_routes, "get_stripe_client", lambda: create_client) monkeypatch.setattr(stripe_routes, "get_stripe_client", lambda: create_client)
monkeypatch.setattr(stripe_routes.config, "STRIPE_PRICE_ID", "price_pages_1000") _configure_credit_buying(monkeypatch)
monkeypatch.setattr(
stripe_routes.config, "NEXT_FRONTEND_URL", "http://localhost:3000"
)
monkeypatch.setattr(stripe_routes.config, "STRIPE_PAGES_PER_UNIT", 1000)
create_response = await client.post( create_response = await client.post(
"/api/v1/stripe/create-checkout-session", "/api/v1/stripe/create-credit-checkout-session",
headers=headers, headers=headers,
json={"quantity": 1, "search_space_id": search_space_id}, json={"quantity": 1, "search_space_id": search_space_id},
) )
@ -479,14 +490,14 @@ class TestStripeReconciliation:
20, 20,
) )
await stripe_reconciliation_task._reconcile_pending_page_purchases() await stripe_reconciliation_task._reconcile_pending_credit_purchases()
assert await _get_pages_limit(TEST_EMAIL) == 500 assert await _get_balance(TEST_EMAIL) == 500_000
purchase = await _fetchrow( purchase = await _fetchrow(
""" """
SELECT status SELECT status
FROM page_purchases FROM credit_purchases
WHERE stripe_checkout_session_id = $1 WHERE stripe_checkout_session_id = $1
""", """,
checkout_session.id, checkout_session.id,

View file

@ -961,24 +961,37 @@ class TestDirectConvert:
# ==================================================================== # ====================================================================
# Tier 8: Page Limits (PL1-PL6) # Tier 8: ETL Credits (CR1-CR6)
# ==================================================================== # ====================================================================
class TestPageLimits: class TestEtlCredits:
@pytest.fixture(autouse=True)
def _enable_etl_billing(self, monkeypatch):
"""Force ETL credit billing on (off by default for self-hosted/OSS)."""
from app.config import config
monkeypatch.setattr(config, "ETL_CREDIT_BILLING_ENABLED", True)
@staticmethod
def _micros(pages: int) -> int:
from app.config import config
return pages * config.MICROS_PER_PAGE
@pytest.mark.usefixtures(*UNIFIED_FIXTURES) @pytest.mark.usefixtures(*UNIFIED_FIXTURES)
async def test_pl1_full_scan_increments_pages_used( async def test_cr1_full_scan_debits_balance(
self, self,
db_session: AsyncSession, db_session: AsyncSession,
db_user: User, db_user: User,
db_search_space: SearchSpace, db_search_space: SearchSpace,
tmp_path: Path, tmp_path: Path,
): ):
"""PL1: Successful full-scan sync increments user.pages_used.""" """CR1: Successful full-scan sync debits user.credit_micros_balance."""
from app.tasks.connector_indexers.local_folder_indexer import index_local_folder from app.tasks.connector_indexers.local_folder_indexer import index_local_folder
db_user.pages_used = 0 starting = self._micros(500)
db_user.pages_limit = 500 db_user.credit_micros_balance = starting
await db_session.flush() await db_session.flush()
(tmp_path / "note.md").write_text("# Hello World\n\nContent here.") (tmp_path / "note.md").write_text("# Hello World\n\nContent here.")
@ -995,21 +1008,22 @@ class TestPageLimits:
assert count == 1 assert count == 1
await db_session.refresh(db_user) await db_session.refresh(db_user)
assert db_user.pages_used > 0, "pages_used should increase after indexing" assert db_user.credit_micros_balance < starting, (
"balance should drop after indexing"
)
@pytest.mark.usefixtures(*UNIFIED_FIXTURES) @pytest.mark.usefixtures(*UNIFIED_FIXTURES)
async def test_pl2_full_scan_blocked_when_limit_exhausted( async def test_cr2_full_scan_blocked_when_credit_exhausted(
self, self,
db_session: AsyncSession, db_session: AsyncSession,
db_user: User, db_user: User,
db_search_space: SearchSpace, db_search_space: SearchSpace,
tmp_path: Path, tmp_path: Path,
): ):
"""PL2: Full-scan skips file when page limit is exhausted.""" """CR2: Full-scan skips file when the wallet is empty."""
from app.tasks.connector_indexers.local_folder_indexer import index_local_folder from app.tasks.connector_indexers.local_folder_indexer import index_local_folder
db_user.pages_used = 100 db_user.credit_micros_balance = 0
db_user.pages_limit = 100
await db_session.flush() await db_session.flush()
(tmp_path / "note.md").write_text("# Hello World\n\nContent here.") (tmp_path / "note.md").write_text("# Hello World\n\nContent here.")
@ -1025,21 +1039,23 @@ class TestPageLimits:
assert count == 0 assert count == 0
await db_session.refresh(db_user) await db_session.refresh(db_user)
assert db_user.pages_used == 100, "pages_used should not change on rejection" assert db_user.credit_micros_balance == 0, (
"balance should not change on rejection"
)
@pytest.mark.usefixtures(*UNIFIED_FIXTURES) @pytest.mark.usefixtures(*UNIFIED_FIXTURES)
async def test_pl3_single_file_increments_pages_used( async def test_cr3_single_file_debits_balance(
self, self,
db_session: AsyncSession, db_session: AsyncSession,
db_user: User, db_user: User,
db_search_space: SearchSpace, db_search_space: SearchSpace,
tmp_path: Path, tmp_path: Path,
): ):
"""PL3: Single-file mode increments user.pages_used on success.""" """CR3: Single-file mode debits balance on success."""
from app.tasks.connector_indexers.local_folder_indexer import index_local_folder from app.tasks.connector_indexers.local_folder_indexer import index_local_folder
db_user.pages_used = 0 starting = self._micros(500)
db_user.pages_limit = 500 db_user.credit_micros_balance = starting
await db_session.flush() await db_session.flush()
(tmp_path / "note.md").write_text("# Hello World\n\nContent here.") (tmp_path / "note.md").write_text("# Hello World\n\nContent here.")
@ -1057,21 +1073,22 @@ class TestPageLimits:
assert count == 1 assert count == 1
await db_session.refresh(db_user) await db_session.refresh(db_user)
assert db_user.pages_used > 0, "pages_used should increase after indexing" assert db_user.credit_micros_balance < starting, (
"balance should drop after indexing"
)
@pytest.mark.usefixtures(*UNIFIED_FIXTURES) @pytest.mark.usefixtures(*UNIFIED_FIXTURES)
async def test_pl4_single_file_blocked_when_limit_exhausted( async def test_cr4_single_file_blocked_when_credit_exhausted(
self, self,
db_session: AsyncSession, db_session: AsyncSession,
db_user: User, db_user: User,
db_search_space: SearchSpace, db_search_space: SearchSpace,
tmp_path: Path, tmp_path: Path,
): ):
"""PL4: Single-file mode skips file when page limit is exhausted.""" """CR4: Single-file mode skips file when the wallet is empty."""
from app.tasks.connector_indexers.local_folder_indexer import index_local_folder from app.tasks.connector_indexers.local_folder_indexer import index_local_folder
db_user.pages_used = 100 db_user.credit_micros_balance = 0
db_user.pages_limit = 100
await db_session.flush() await db_session.flush()
(tmp_path / "note.md").write_text("# Hello World\n\nContent here.") (tmp_path / "note.md").write_text("# Hello World\n\nContent here.")
@ -1087,24 +1104,25 @@ class TestPageLimits:
assert count == 0 assert count == 0
assert err is not None assert err is not None
assert "page limit" in err.lower() assert "credit" in err.lower()
await db_session.refresh(db_user) await db_session.refresh(db_user)
assert db_user.pages_used == 100, "pages_used should not change on rejection" assert db_user.credit_micros_balance == 0, (
"balance should not change on rejection"
)
@pytest.mark.usefixtures(*UNIFIED_FIXTURES) @pytest.mark.usefixtures(*UNIFIED_FIXTURES)
async def test_pl5_unchanged_resync_no_extra_pages( async def test_cr5_unchanged_resync_no_extra_debit(
self, self,
db_session: AsyncSession, db_session: AsyncSession,
db_user: User, db_user: User,
db_search_space: SearchSpace, db_search_space: SearchSpace,
tmp_path: Path, tmp_path: Path,
): ):
"""PL5: Re-syncing an unchanged file does not consume additional pages.""" """CR5: Re-syncing an unchanged file does not consume additional credit."""
from app.tasks.connector_indexers.local_folder_indexer import index_local_folder from app.tasks.connector_indexers.local_folder_indexer import index_local_folder
db_user.pages_used = 0 db_user.credit_micros_balance = self._micros(500)
db_user.pages_limit = 500
await db_session.flush() await db_session.flush()
(tmp_path / "note.md").write_text("# Hello\n\nSame content.") (tmp_path / "note.md").write_text("# Hello\n\nSame content.")
@ -1119,8 +1137,8 @@ class TestPageLimits:
assert count1 == 1 assert count1 == 1
await db_session.refresh(db_user) await db_session.refresh(db_user)
pages_after_first = db_user.pages_used balance_after_first = db_user.credit_micros_balance
assert pages_after_first > 0 assert balance_after_first < self._micros(500)
count2, _, _, _ = await index_local_folder( count2, _, _, _ = await index_local_folder(
session=db_session, session=db_session,
@ -1133,12 +1151,12 @@ class TestPageLimits:
assert count2 == 0 assert count2 == 0
await db_session.refresh(db_user) await db_session.refresh(db_user)
assert db_user.pages_used == pages_after_first, ( assert db_user.credit_micros_balance == balance_after_first, (
"pages_used should not increase for unchanged files" "balance should not change for unchanged files"
) )
@pytest.mark.usefixtures(*UNIFIED_FIXTURES) @pytest.mark.usefixtures(*UNIFIED_FIXTURES)
async def test_pl6_batch_partial_page_limit_exhaustion( async def test_cr6_batch_partial_credit_exhaustion(
self, self,
db_session: AsyncSession, db_session: AsyncSession,
db_user: User, db_user: User,
@ -1146,11 +1164,11 @@ class TestPageLimits:
tmp_path: Path, tmp_path: Path,
patched_batch_sessions, patched_batch_sessions,
): ):
"""PL6: Batch mode with a very low page limit: some files succeed, rest fail.""" """CR6: Batch mode with a tiny balance: some files succeed, rest fail."""
from app.tasks.connector_indexers.local_folder_indexer import index_local_folder from app.tasks.connector_indexers.local_folder_indexer import index_local_folder
db_user.pages_used = 0 # Exactly one page of credit.
db_user.pages_limit = 1 db_user.credit_micros_balance = self._micros(1)
await db_session.flush() await db_session.flush()
(tmp_path / "a.md").write_text("File A content") (tmp_path / "a.md").write_text("File A content")
@ -1171,12 +1189,13 @@ class TestPageLimits:
) )
assert count >= 1, "at least one file should succeed" assert count >= 1, "at least one file should succeed"
assert failed >= 1, "at least one file should fail due to page limit" assert failed >= 1, "at least one file should fail due to insufficient credits"
assert count + failed == 3 assert count + failed == 3
await db_session.refresh(db_user) await db_session.refresh(db_user)
assert db_user.pages_used > 0 # The wallet was drained by the successful file(s); it may dip slightly
assert db_user.pages_used <= db_user.pages_limit + 1 # negative when the actual page count exceeds the pre-check estimate.
assert db_user.credit_micros_balance <= 0
# ==================================================================== # ====================================================================

View file

@ -1,4 +1,4 @@
"""Behavior guard for the page-limit notification handler.""" """Behavior guard for the insufficient-credits notification handler."""
from __future__ import annotations from __future__ import annotations
@ -10,52 +10,50 @@ from app.notifications.service import NotificationService
pytestmark = pytest.mark.integration pytestmark = pytest.mark.integration
handler = NotificationService.page_limit handler = NotificationService.insufficient_credits
async def test_page_limit_message_and_action( async def test_insufficient_credits_message_and_action(
db_session: AsyncSession, db_user: User, db_search_space: SearchSpace db_session: AsyncSession, db_user: User, db_search_space: SearchSpace
): ):
"""A page-limit notification states usage and carries an upgrade action link.""" """An insufficient-credits notification states cost and carries a buy-credits link."""
notification = await handler.notify_page_limit_exceeded( notification = await handler.notify_insufficient_credits(
session=db_session, session=db_session,
user_id=db_user.id, user_id=db_user.id,
document_name="short.pdf", document_name="short.pdf",
document_type="FILE", document_type="FILE",
search_space_id=db_search_space.id, search_space_id=db_search_space.id,
pages_used=95, balance_micros=250_000,
pages_limit=100, required_micros=1_000_000,
pages_to_add=10,
) )
assert notification.type == "page_limit_exceeded" assert notification.type == "insufficient_credits"
assert notification.title == "Page limit exceeded: short.pdf" assert notification.title == "Insufficient credits: short.pdf"
assert notification.message == ( assert notification.message == (
"This document has ~10 page(s) but you've used 95/100 pages. " "This document costs about $1.00 to process but you have "
"Upgrade to process more documents." "$0.25 of credit left. Add more credits to continue."
) )
assert notification.notification_metadata["status"] == "failed" assert notification.notification_metadata["status"] == "failed"
assert notification.notification_metadata["action_label"] == "Upgrade Plan" assert notification.notification_metadata["action_label"] == "Buy credits"
assert notification.notification_metadata["action_url"] == ( assert notification.notification_metadata["action_url"] == (
f"/dashboard/{db_search_space.id}/more-pages" f"/dashboard/{db_search_space.id}/buy-more"
) )
async def test_page_limit_truncates_long_name( async def test_insufficient_credits_truncates_long_name(
db_session: AsyncSession, db_user: User, db_search_space: SearchSpace db_session: AsyncSession, db_user: User, db_search_space: SearchSpace
): ):
"""A long document name is truncated in the notification title.""" """A long document name is truncated in the notification title."""
long_name = "a" * 50 long_name = "a" * 50
notification = await handler.notify_page_limit_exceeded( notification = await handler.notify_insufficient_credits(
session=db_session, session=db_session,
user_id=db_user.id, user_id=db_user.id,
document_name=long_name, document_name=long_name,
document_type="FILE", document_type="FILE",
search_space_id=db_search_space.id, search_space_id=db_search_space.id,
pages_used=95, balance_micros=250_000,
pages_limit=100, required_micros=1_000_000,
pages_to_add=10,
) )
assert notification.title == f"Page limit exceeded: {'a' * 40}..." assert notification.title == f"Insufficient credits: {'a' * 40}..."

View file

@ -272,22 +272,26 @@ def full_scan_mocks(mock_dropbox_client, monkeypatch):
download_and_index_mock = AsyncMock(return_value=(0, 0)) download_and_index_mock = AsyncMock(return_value=(0, 0))
monkeypatch.setattr(_mod, "_download_and_index", download_and_index_mock) monkeypatch.setattr(_mod, "_download_and_index", download_and_index_mock)
from app.services.page_limit_service import PageLimitService as _RealPLS from app.services.etl_credit_service import EtlCreditService as _RealECS
mock_page_limit_instance = MagicMock() # get_available_micros -> None means "unlimited" (billing disabled), so no
mock_page_limit_instance.get_page_usage = AsyncMock(return_value=(0, 999_999)) # batch is gated and charge_credits is a no-op — matching the prior
mock_page_limit_instance.update_page_usage = AsyncMock() # 999_999 page-limit intent for these parallel-processing tests.
mock_credit_instance = MagicMock()
mock_credit_instance.get_available_micros = AsyncMock(return_value=None)
mock_credit_instance.charge_credits = AsyncMock(return_value=None)
class _MockPageLimitService: class _MockEtlCreditService:
estimate_pages_from_metadata = staticmethod( estimate_pages_from_metadata = staticmethod(
_RealPLS.estimate_pages_from_metadata _RealECS.estimate_pages_from_metadata
) )
pages_to_micros = staticmethod(_RealECS.pages_to_micros)
def __init__(self, session): def __init__(self, session):
self.get_page_usage = mock_page_limit_instance.get_page_usage self.get_available_micros = mock_credit_instance.get_available_micros
self.update_page_usage = mock_page_limit_instance.update_page_usage self.charge_credits = mock_credit_instance.charge_credits
monkeypatch.setattr(_mod, "PageLimitService", _MockPageLimitService) monkeypatch.setattr(_mod, "EtlCreditService", _MockEtlCreditService)
return { return {
"dropbox_client": mock_dropbox_client, "dropbox_client": mock_dropbox_client,
@ -393,22 +397,23 @@ def selected_files_mocks(mock_dropbox_client, monkeypatch):
download_and_index_mock = AsyncMock(return_value=(0, 0)) download_and_index_mock = AsyncMock(return_value=(0, 0))
monkeypatch.setattr(_mod, "_download_and_index", download_and_index_mock) monkeypatch.setattr(_mod, "_download_and_index", download_and_index_mock)
from app.services.page_limit_service import PageLimitService as _RealPLS from app.services.etl_credit_service import EtlCreditService as _RealECS
mock_page_limit_instance = MagicMock() mock_credit_instance = MagicMock()
mock_page_limit_instance.get_page_usage = AsyncMock(return_value=(0, 999_999)) mock_credit_instance.get_available_micros = AsyncMock(return_value=None)
mock_page_limit_instance.update_page_usage = AsyncMock() mock_credit_instance.charge_credits = AsyncMock(return_value=None)
class _MockPageLimitService: class _MockEtlCreditService:
estimate_pages_from_metadata = staticmethod( estimate_pages_from_metadata = staticmethod(
_RealPLS.estimate_pages_from_metadata _RealECS.estimate_pages_from_metadata
) )
pages_to_micros = staticmethod(_RealECS.pages_to_micros)
def __init__(self, session): def __init__(self, session):
self.get_page_usage = mock_page_limit_instance.get_page_usage self.get_available_micros = mock_credit_instance.get_available_micros
self.update_page_usage = mock_page_limit_instance.update_page_usage self.charge_credits = mock_credit_instance.charge_credits
monkeypatch.setattr(_mod, "PageLimitService", _MockPageLimitService) monkeypatch.setattr(_mod, "EtlCreditService", _MockEtlCreditService)
return { return {
"dropbox_client": mock_dropbox_client, "dropbox_client": mock_dropbox_client,

View file

@ -1,17 +1,22 @@
"""Tests for page limit enforcement in connector indexers. """Tests for ETL credit enforcement in connector indexers.
Covers: Covers:
A) PageLimitService.estimate_pages_from_metadata pure function (no mocks) A) EtlCreditService.estimate_pages_from_metadata pure function (no mocks)
B) Page-limit quota gating in _index_selected_files tested through the B) Credit-wallet gating in the connector indexers, tested through the real
real PageLimitService with a mock DB session (system boundary). EtlCreditService with a mock DB session (system boundary). ETL credit
billing is force-enabled per-test so the gating path is exercised.
Google Drive is the primary, with OneDrive/Dropbox smoke tests. Google Drive is the primary, with OneDrive/Dropbox smoke tests.
Page estimates are converted to micro-USD at ``config.MICROS_PER_PAGE`` per
page and debited from ``user.credit_micros_balance``.
""" """
from unittest.mock import AsyncMock, MagicMock from unittest.mock import AsyncMock, MagicMock
import pytest import pytest
from app.services.page_limit_service import PageLimitService from app.config import config
from app.services.etl_credit_service import EtlCreditService
pytestmark = pytest.mark.unit pytestmark = pytest.mark.unit
@ -20,8 +25,23 @@ _CONNECTOR_ID = 42
_SEARCH_SPACE_ID = 1 _SEARCH_SPACE_ID = 1
def _micros(pages: int) -> int:
"""Convert a page count to micro-USD using the configured rate."""
return pages * config.MICROS_PER_PAGE
@pytest.fixture(autouse=True)
def _enable_etl_billing(monkeypatch):
"""Force ETL credit billing on so the gating/charging path runs.
It defaults to off (self-hosted/OSS), which would short-circuit
get_available_micros to None and bypass every check in this module.
"""
monkeypatch.setattr(config, "ETL_CREDIT_BILLING_ENABLED", True)
# =================================================================== # ===================================================================
# A) PageLimitService.estimate_pages_from_metadata — pure function # A) EtlCreditService.estimate_pages_from_metadata — pure function
# No mocks: it's a staticmethod with no I/O. # No mocks: it's a staticmethod with no I/O.
# =================================================================== # ===================================================================
@ -30,88 +50,91 @@ class TestEstimatePagesFromMetadata:
"""Vertical slices for the page estimation staticmethod.""" """Vertical slices for the page estimation staticmethod."""
def test_pdf_100kb_returns_1(self): def test_pdf_100kb_returns_1(self):
assert PageLimitService.estimate_pages_from_metadata(".pdf", 100 * 1024) == 1 assert EtlCreditService.estimate_pages_from_metadata(".pdf", 100 * 1024) == 1
def test_pdf_500kb_returns_5(self): def test_pdf_500kb_returns_5(self):
assert PageLimitService.estimate_pages_from_metadata(".pdf", 500 * 1024) == 5 assert EtlCreditService.estimate_pages_from_metadata(".pdf", 500 * 1024) == 5
def test_pdf_1mb(self): def test_pdf_1mb(self):
assert PageLimitService.estimate_pages_from_metadata(".pdf", 1024 * 1024) == 10 assert EtlCreditService.estimate_pages_from_metadata(".pdf", 1024 * 1024) == 10
def test_docx_50kb_returns_1(self): def test_docx_50kb_returns_1(self):
assert PageLimitService.estimate_pages_from_metadata(".docx", 50 * 1024) == 1 assert EtlCreditService.estimate_pages_from_metadata(".docx", 50 * 1024) == 1
def test_docx_200kb(self): def test_docx_200kb(self):
assert PageLimitService.estimate_pages_from_metadata(".docx", 200 * 1024) == 4 assert EtlCreditService.estimate_pages_from_metadata(".docx", 200 * 1024) == 4
def test_pptx_uses_200kb_per_page(self): def test_pptx_uses_200kb_per_page(self):
assert PageLimitService.estimate_pages_from_metadata(".pptx", 600 * 1024) == 3 assert EtlCreditService.estimate_pages_from_metadata(".pptx", 600 * 1024) == 3
def test_xlsx_uses_100kb_per_page(self): def test_xlsx_uses_100kb_per_page(self):
assert PageLimitService.estimate_pages_from_metadata(".xlsx", 300 * 1024) == 3 assert EtlCreditService.estimate_pages_from_metadata(".xlsx", 300 * 1024) == 3
def test_txt_uses_3000_bytes_per_page(self): def test_txt_uses_3000_bytes_per_page(self):
assert PageLimitService.estimate_pages_from_metadata(".txt", 9000) == 3 assert EtlCreditService.estimate_pages_from_metadata(".txt", 9000) == 3
def test_image_always_returns_1(self): def test_image_always_returns_1(self):
for ext in (".jpg", ".png", ".gif", ".webp"): for ext in (".jpg", ".png", ".gif", ".webp"):
assert PageLimitService.estimate_pages_from_metadata(ext, 5_000_000) == 1 assert EtlCreditService.estimate_pages_from_metadata(ext, 5_000_000) == 1
def test_audio_uses_1mb_per_page(self): def test_audio_uses_1mb_per_page(self):
assert ( assert (
PageLimitService.estimate_pages_from_metadata(".mp3", 3 * 1024 * 1024) == 3 EtlCreditService.estimate_pages_from_metadata(".mp3", 3 * 1024 * 1024) == 3
) )
def test_video_uses_5mb_per_page(self): def test_video_uses_5mb_per_page(self):
assert ( assert (
PageLimitService.estimate_pages_from_metadata(".mp4", 15 * 1024 * 1024) == 3 EtlCreditService.estimate_pages_from_metadata(".mp4", 15 * 1024 * 1024) == 3
) )
def test_unknown_ext_uses_80kb_per_page(self): def test_unknown_ext_uses_80kb_per_page(self):
assert PageLimitService.estimate_pages_from_metadata(".xyz", 160 * 1024) == 2 assert EtlCreditService.estimate_pages_from_metadata(".xyz", 160 * 1024) == 2
def test_zero_size_returns_1(self): def test_zero_size_returns_1(self):
assert PageLimitService.estimate_pages_from_metadata(".pdf", 0) == 1 assert EtlCreditService.estimate_pages_from_metadata(".pdf", 0) == 1
def test_negative_size_returns_1(self): def test_negative_size_returns_1(self):
assert PageLimitService.estimate_pages_from_metadata(".pdf", -500) == 1 assert EtlCreditService.estimate_pages_from_metadata(".pdf", -500) == 1
def test_minimum_is_always_1(self): def test_minimum_is_always_1(self):
assert PageLimitService.estimate_pages_from_metadata(".pdf", 50) == 1 assert EtlCreditService.estimate_pages_from_metadata(".pdf", 50) == 1
def test_epub_uses_50kb_per_page(self): def test_epub_uses_50kb_per_page(self):
assert PageLimitService.estimate_pages_from_metadata(".epub", 250 * 1024) == 5 assert EtlCreditService.estimate_pages_from_metadata(".epub", 250 * 1024) == 5
# =================================================================== # ===================================================================
# B) Page-limit enforcement in connector indexers # B) Credit enforcement in connector indexers
# System boundary mocked: DB session (for PageLimitService) # System boundary mocked: DB session (for EtlCreditService)
# System boundary mocked: external API clients, download/ETL # System boundary mocked: external API clients, download/ETL
# NOT mocked: PageLimitService itself (our own code) # NOT mocked: EtlCreditService itself (our own code)
# =================================================================== # ===================================================================
class _FakeUser: class _FakeUser:
"""Stands in for the User ORM model at the DB boundary.""" """Stands in for the User ORM model at the DB boundary."""
def __init__(self, pages_used: int = 0, pages_limit: int = 100): def __init__(self, balance_micros: int = 0, reserved_micros: int = 0):
self.pages_used = pages_used self.credit_micros_balance = balance_micros
self.pages_limit = pages_limit self.credit_micros_reserved = reserved_micros
def _make_page_limit_session(pages_used: int = 0, pages_limit: int = 100): def _make_credit_session(balance_micros: int = _micros(100), reserved_micros: int = 0):
"""Build a mock DB session that real PageLimitService can operate against. """Build a mock DB session that the real EtlCreditService can operate against.
Every ``session.execute()`` returns a result compatible with both Every ``session.execute()`` returns a result compatible with both
``get_page_usage`` (.first() tuple) and ``update_page_usage`` ``get_available_micros`` (.first() ``(balance, reserved)``) and
(.unique().scalar_one_or_none() User-like). ``charge_credits`` (.unique().scalar_one_or_none() User-like).
""" """
fake_user = _FakeUser(pages_used, pages_limit) fake_user = _FakeUser(balance_micros, reserved_micros)
session = AsyncMock() session = AsyncMock()
def _make_result(*_args, **_kwargs): def _make_result(*_args, **_kwargs):
result = MagicMock() result = MagicMock()
result.first.return_value = (fake_user.pages_used, fake_user.pages_limit) result.first.return_value = (
fake_user.credit_micros_balance,
fake_user.credit_micros_reserved,
)
result.unique.return_value.scalar_one_or_none.return_value = fake_user result.unique.return_value.scalar_one_or_none.return_value = fake_user
return result return result
@ -138,7 +161,7 @@ def gdrive_selected_mocks(monkeypatch):
"""Mocks for Google Drive _index_selected_files — only system boundaries.""" """Mocks for Google Drive _index_selected_files — only system boundaries."""
import app.tasks.connector_indexers.google_drive_indexer as _mod import app.tasks.connector_indexers.google_drive_indexer as _mod
session, fake_user = _make_page_limit_session(0, 100) session, fake_user = _make_credit_session(_micros(100))
get_file_results: dict[str, tuple[dict | None, str | None]] = {} get_file_results: dict[str, tuple[dict | None, str | None]] = {}
@ -183,12 +206,11 @@ async def _run_gdrive_selected(mocks, file_ids):
) )
async def test_gdrive_files_within_quota_are_downloaded(gdrive_selected_mocks): async def test_gdrive_files_within_credit_are_downloaded(gdrive_selected_mocks):
"""Files whose cumulative estimated pages fit within remaining quota """Files whose cumulative estimated cost fits within available credit
are sent to _download_and_index.""" are sent to _download_and_index."""
m = gdrive_selected_mocks m = gdrive_selected_mocks
m["fake_user"].pages_used = 0 m["fake_user"].credit_micros_balance = _micros(100)
m["fake_user"].pages_limit = 100
for fid in ("f1", "f2", "f3"): for fid in ("f1", "f2", "f3"):
m["get_file_results"][fid] = ( m["get_file_results"][fid] = (
@ -207,11 +229,10 @@ async def test_gdrive_files_within_quota_are_downloaded(gdrive_selected_mocks):
assert len(call_files) == 3 assert len(call_files) == 3
async def test_gdrive_files_exceeding_quota_rejected(gdrive_selected_mocks): async def test_gdrive_files_exceeding_credit_rejected(gdrive_selected_mocks):
"""Files whose pages would exceed remaining quota are rejected.""" """Files whose cost would exceed available credit are rejected."""
m = gdrive_selected_mocks m = gdrive_selected_mocks
m["fake_user"].pages_used = 98 m["fake_user"].credit_micros_balance = _micros(2)
m["fake_user"].pages_limit = 100
m["get_file_results"]["big"] = ( m["get_file_results"]["big"] = (
_make_gdrive_file("big", "huge.pdf", size=500 * 1024), _make_gdrive_file("big", "huge.pdf", size=500 * 1024),
@ -224,14 +245,13 @@ async def test_gdrive_files_exceeding_quota_rejected(gdrive_selected_mocks):
assert indexed == 0 assert indexed == 0
assert len(errors) == 1 assert len(errors) == 1
assert "page limit" in errors[0].lower() assert "insufficient credits" in errors[0].lower()
async def test_gdrive_quota_mix_partial_indexing(gdrive_selected_mocks): async def test_gdrive_credit_mix_partial_indexing(gdrive_selected_mocks):
"""3rd file pushes over quota → only first two indexed.""" """3rd file pushes over available credit → only first two indexed."""
m = gdrive_selected_mocks m = gdrive_selected_mocks
m["fake_user"].pages_used = 0 m["fake_user"].credit_micros_balance = _micros(2)
m["fake_user"].pages_limit = 2
for fid in ("f1", "f2", "f3"): for fid in ("f1", "f2", "f3"):
m["get_file_results"][fid] = ( m["get_file_results"][fid] = (
@ -250,11 +270,10 @@ async def test_gdrive_quota_mix_partial_indexing(gdrive_selected_mocks):
assert {f["id"] for f in call_files} == {"f1", "f2"} assert {f["id"] for f in call_files} == {"f1", "f2"}
async def test_gdrive_proportional_page_deduction(gdrive_selected_mocks): async def test_gdrive_proportional_credit_deduction(gdrive_selected_mocks):
"""Pages deducted are proportional to successfully indexed files.""" """Credit deducted is proportional to successfully indexed files."""
m = gdrive_selected_mocks m = gdrive_selected_mocks
m["fake_user"].pages_used = 0 m["fake_user"].credit_micros_balance = _micros(100)
m["fake_user"].pages_limit = 100
for fid in ("f1", "f2", "f3", "f4"): for fid in ("f1", "f2", "f3", "f4"):
m["get_file_results"][fid] = ( m["get_file_results"][fid] = (
@ -268,14 +287,14 @@ async def test_gdrive_proportional_page_deduction(gdrive_selected_mocks):
[("f1", "f1.xyz"), ("f2", "f2.xyz"), ("f3", "f3.xyz"), ("f4", "f4.xyz")], [("f1", "f1.xyz"), ("f2", "f2.xyz"), ("f3", "f3.xyz"), ("f4", "f4.xyz")],
) )
assert m["fake_user"].pages_used == 2 # 4 estimated pages, 2 of 4 indexed → deduct 2 pages.
assert m["fake_user"].credit_micros_balance == _micros(100) - _micros(2)
async def test_gdrive_no_deduction_when_nothing_indexed(gdrive_selected_mocks): async def test_gdrive_no_deduction_when_nothing_indexed(gdrive_selected_mocks):
"""If batch_indexed == 0, user's pages_used stays unchanged.""" """If batch_indexed == 0, the user's balance stays unchanged."""
m = gdrive_selected_mocks m = gdrive_selected_mocks
m["fake_user"].pages_used = 5 m["fake_user"].credit_micros_balance = _micros(95)
m["fake_user"].pages_limit = 100
m["get_file_results"]["f1"] = ( m["get_file_results"]["f1"] = (
_make_gdrive_file("f1", "f1.xyz", size=80 * 1024), _make_gdrive_file("f1", "f1.xyz", size=80 * 1024),
@ -285,14 +304,13 @@ async def test_gdrive_no_deduction_when_nothing_indexed(gdrive_selected_mocks):
await _run_gdrive_selected(m, [("f1", "f1.xyz")]) await _run_gdrive_selected(m, [("f1", "f1.xyz")])
assert m["fake_user"].pages_used == 5 assert m["fake_user"].credit_micros_balance == _micros(95)
async def test_gdrive_zero_quota_rejects_all(gdrive_selected_mocks): async def test_gdrive_zero_credit_rejects_all(gdrive_selected_mocks):
"""When pages_used == pages_limit, every file is rejected.""" """When the balance is exhausted, every file is rejected."""
m = gdrive_selected_mocks m = gdrive_selected_mocks
m["fake_user"].pages_used = 100 m["fake_user"].credit_micros_balance = 0
m["fake_user"].pages_limit = 100
for fid in ("f1", "f2"): for fid in ("f1", "f2"):
m["get_file_results"][fid] = ( m["get_file_results"][fid] = (
@ -317,7 +335,7 @@ async def test_gdrive_zero_quota_rejects_all(gdrive_selected_mocks):
def gdrive_full_scan_mocks(monkeypatch): def gdrive_full_scan_mocks(monkeypatch):
import app.tasks.connector_indexers.google_drive_indexer as _mod import app.tasks.connector_indexers.google_drive_indexer as _mod
session, fake_user = _make_page_limit_session(0, 100) session, fake_user = _make_credit_session(_micros(100))
mock_task_logger = MagicMock() mock_task_logger = MagicMock()
mock_task_logger.log_task_progress = AsyncMock() mock_task_logger.log_task_progress = AsyncMock()
@ -364,10 +382,9 @@ async def _run_gdrive_full_scan(mocks, max_files=500):
) )
async def test_gdrive_full_scan_skips_over_quota(gdrive_full_scan_mocks, monkeypatch): async def test_gdrive_full_scan_skips_over_credit(gdrive_full_scan_mocks, monkeypatch):
m = gdrive_full_scan_mocks m = gdrive_full_scan_mocks
m["fake_user"].pages_used = 0 m["fake_user"].credit_micros_balance = _micros(2)
m["fake_user"].pages_limit = 2
page_files = [ page_files = [
_make_gdrive_file(f"f{i}", f"file{i}.xyz", size=80 * 1024) for i in range(5) _make_gdrive_file(f"f{i}", f"file{i}.xyz", size=80 * 1024) for i in range(5)
@ -391,8 +408,7 @@ async def test_gdrive_full_scan_deducts_after_indexing(
gdrive_full_scan_mocks, monkeypatch gdrive_full_scan_mocks, monkeypatch
): ):
m = gdrive_full_scan_mocks m = gdrive_full_scan_mocks
m["fake_user"].pages_used = 0 m["fake_user"].credit_micros_balance = _micros(100)
m["fake_user"].pages_limit = 100
page_files = [ page_files = [
_make_gdrive_file(f"f{i}", f"file{i}.xyz", size=80 * 1024) for i in range(3) _make_gdrive_file(f"f{i}", f"file{i}.xyz", size=80 * 1024) for i in range(3)
@ -408,7 +424,7 @@ async def test_gdrive_full_scan_deducts_after_indexing(
await _run_gdrive_full_scan(m) await _run_gdrive_full_scan(m)
assert m["fake_user"].pages_used == 3 assert m["fake_user"].credit_micros_balance == _micros(100) - _micros(3)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@ -416,10 +432,10 @@ async def test_gdrive_full_scan_deducts_after_indexing(
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
async def test_gdrive_delta_sync_skips_over_quota(monkeypatch): async def test_gdrive_delta_sync_skips_over_credit(monkeypatch):
import app.tasks.connector_indexers.google_drive_indexer as _mod import app.tasks.connector_indexers.google_drive_indexer as _mod
session, _ = _make_page_limit_session(0, 2) session, _ = _make_credit_session(_micros(2))
changes = [ changes = [
{ {
@ -471,7 +487,7 @@ async def test_gdrive_delta_sync_skips_over_quota(monkeypatch):
# =================================================================== # ===================================================================
# C) OneDrive smoke tests — verify page limit wiring # C) OneDrive smoke tests — verify credit wiring
# =================================================================== # ===================================================================
@ -489,7 +505,7 @@ def _make_onedrive_file(file_id: str, name: str, size: int = 80 * 1024) -> dict:
def onedrive_selected_mocks(monkeypatch): def onedrive_selected_mocks(monkeypatch):
import app.tasks.connector_indexers.onedrive_indexer as _mod import app.tasks.connector_indexers.onedrive_indexer as _mod
session, fake_user = _make_page_limit_session(0, 100) session, fake_user = _make_credit_session(_micros(100))
get_file_results: dict[str, tuple[dict | None, str | None]] = {} get_file_results: dict[str, tuple[dict | None, str | None]] = {}
@ -531,11 +547,10 @@ async def _run_onedrive_selected(mocks, file_ids):
) )
async def test_onedrive_over_quota_rejected(onedrive_selected_mocks): async def test_onedrive_over_credit_rejected(onedrive_selected_mocks):
"""OneDrive: files exceeding quota produce errors, not downloads.""" """OneDrive: files exceeding available credit produce errors, not downloads."""
m = onedrive_selected_mocks m = onedrive_selected_mocks
m["fake_user"].pages_used = 99 m["fake_user"].credit_micros_balance = _micros(1)
m["fake_user"].pages_limit = 100
m["get_file_results"]["big"] = ( m["get_file_results"]["big"] = (
_make_onedrive_file("big", "huge.pdf", size=500 * 1024), _make_onedrive_file("big", "huge.pdf", size=500 * 1024),
@ -548,14 +563,13 @@ async def test_onedrive_over_quota_rejected(onedrive_selected_mocks):
assert indexed == 0 assert indexed == 0
assert len(errors) == 1 assert len(errors) == 1
assert "page limit" in errors[0].lower() assert "insufficient credits" in errors[0].lower()
async def test_onedrive_deducts_after_success(onedrive_selected_mocks): async def test_onedrive_deducts_after_success(onedrive_selected_mocks):
"""OneDrive: pages_used increases after successful indexing.""" """OneDrive: balance decreases after successful indexing."""
m = onedrive_selected_mocks m = onedrive_selected_mocks
m["fake_user"].pages_used = 0 m["fake_user"].credit_micros_balance = _micros(100)
m["fake_user"].pages_limit = 100
for fid in ("f1", "f2"): for fid in ("f1", "f2"):
m["get_file_results"][fid] = ( m["get_file_results"][fid] = (
@ -566,11 +580,11 @@ async def test_onedrive_deducts_after_success(onedrive_selected_mocks):
await _run_onedrive_selected(m, [("f1", "f1.xyz"), ("f2", "f2.xyz")]) await _run_onedrive_selected(m, [("f1", "f1.xyz"), ("f2", "f2.xyz")])
assert m["fake_user"].pages_used == 2 assert m["fake_user"].credit_micros_balance == _micros(100) - _micros(2)
# =================================================================== # ===================================================================
# D) Dropbox smoke tests — verify page limit wiring # D) Dropbox smoke tests — verify credit wiring
# =================================================================== # ===================================================================
@ -590,7 +604,7 @@ def _make_dropbox_file(file_path: str, name: str, size: int = 80 * 1024) -> dict
def dropbox_selected_mocks(monkeypatch): def dropbox_selected_mocks(monkeypatch):
import app.tasks.connector_indexers.dropbox_indexer as _mod import app.tasks.connector_indexers.dropbox_indexer as _mod
session, fake_user = _make_page_limit_session(0, 100) session, fake_user = _make_credit_session(_micros(100))
get_file_results: dict[str, tuple[dict | None, str | None]] = {} get_file_results: dict[str, tuple[dict | None, str | None]] = {}
@ -632,11 +646,10 @@ async def _run_dropbox_selected(mocks, file_paths):
) )
async def test_dropbox_over_quota_rejected(dropbox_selected_mocks): async def test_dropbox_over_credit_rejected(dropbox_selected_mocks):
"""Dropbox: files exceeding quota produce errors, not downloads.""" """Dropbox: files exceeding available credit produce errors, not downloads."""
m = dropbox_selected_mocks m = dropbox_selected_mocks
m["fake_user"].pages_used = 99 m["fake_user"].credit_micros_balance = _micros(1)
m["fake_user"].pages_limit = 100
m["get_file_results"]["/huge.pdf"] = ( m["get_file_results"]["/huge.pdf"] = (
_make_dropbox_file("/huge.pdf", "huge.pdf", size=500 * 1024), _make_dropbox_file("/huge.pdf", "huge.pdf", size=500 * 1024),
@ -649,14 +662,13 @@ async def test_dropbox_over_quota_rejected(dropbox_selected_mocks):
assert indexed == 0 assert indexed == 0
assert len(errors) == 1 assert len(errors) == 1
assert "page limit" in errors[0].lower() assert "insufficient credits" in errors[0].lower()
async def test_dropbox_deducts_after_success(dropbox_selected_mocks): async def test_dropbox_deducts_after_success(dropbox_selected_mocks):
"""Dropbox: pages_used increases after successful indexing.""" """Dropbox: balance decreases after successful indexing."""
m = dropbox_selected_mocks m = dropbox_selected_mocks
m["fake_user"].pages_used = 0 m["fake_user"].credit_micros_balance = _micros(100)
m["fake_user"].pages_limit = 100
for name in ("f1.xyz", "f2.xyz"): for name in ("f1.xyz", "f2.xyz"):
path = f"/{name}" path = f"/{name}"
@ -668,4 +680,4 @@ async def test_dropbox_deducts_after_success(dropbox_selected_mocks):
await _run_dropbox_selected(m, [("/f1.xyz", "f1.xyz"), ("/f2.xyz", "f2.xyz")]) await _run_dropbox_selected(m, [("/f1.xyz", "f1.xyz"), ("/f2.xyz", "f2.xyz")])
assert m["fake_user"].pages_used == 2 assert m["fake_user"].credit_micros_balance == _micros(100) - _micros(2)

View file

@ -242,20 +242,28 @@ def _folder_dict(file_id: str, name: str) -> dict:
} }
def _make_page_limit_session(pages_used=0, pages_limit=999_999): def _make_page_limit_session(balance_micros=999_999_000, reserved_micros=0):
"""Build a mock DB session that real PageLimitService can operate against.""" """Build a mock DB session that real EtlCreditService can operate against.
ETL credit billing is disabled by default in tests, so get_available_micros
short-circuits to None ("unlimited") and these fields are unused; they're
provided for parity if a test opts into billing.
"""
class _FakeUser: class _FakeUser:
def __init__(self, pu, pl): def __init__(self, balance, reserved):
self.pages_used = pu self.credit_micros_balance = balance
self.pages_limit = pl self.credit_micros_reserved = reserved
fake_user = _FakeUser(pages_used, pages_limit) fake_user = _FakeUser(balance_micros, reserved_micros)
session = AsyncMock() session = AsyncMock()
def _make_result(*_a, **_kw): def _make_result(*_a, **_kw):
r = MagicMock() r = MagicMock()
r.first.return_value = (fake_user.pages_used, fake_user.pages_limit) r.first.return_value = (
fake_user.credit_micros_balance,
fake_user.credit_micros_reserved,
)
r.unique.return_value.scalar_one_or_none.return_value = fake_user r.unique.return_value.scalar_one_or_none.return_value = fake_user
return r return r

View file

@ -0,0 +1,38 @@
"""Unit tests for insufficient-credits presentation logic."""
from __future__ import annotations
import pytest
from app.notifications.service.messages import insufficient_credits as msg
pytestmark = pytest.mark.unit
def test_operation_id_encodes_search_space():
"""The operation id embeds the search space id."""
assert msg.operation_id("doc.pdf", 9).startswith("insufficient_credits_9_")
def test_summary_title_and_message():
"""The summary states the document and the required/available credit."""
title, message = msg.summary(
"short.pdf", balance_micros=250_000, required_micros=1_000_000
)
assert title == "Insufficient credits: short.pdf"
assert message == (
"This document costs about $1.00 to process but you have "
"$0.25 of credit left. Add more credits to continue."
)
def test_summary_clamps_negative_balance_to_zero():
"""A negative balance is clamped to $0.00 in the message."""
_, message = msg.summary("doc.pdf", balance_micros=-5_000, required_micros=500_000)
assert "$0.00 of credit left" in message
def test_summary_truncates_long_name():
"""A long document name is truncated in the title."""
title, _ = msg.summary("a" * 50, balance_micros=0, required_micros=1_000)
assert title == f"Insufficient credits: {'a' * 40}..."

View file

@ -1,32 +0,0 @@
"""Unit tests for page-limit presentation logic."""
from __future__ import annotations
import pytest
from app.notifications.service.messages import page_limit as msg
pytestmark = pytest.mark.unit
def test_operation_id_encodes_search_space():
"""The operation id embeds the search space id."""
assert msg.operation_id("doc.pdf", 9).startswith("page_limit_9_")
def test_summary_title_and_message():
"""The summary states the document and the used/limit page counts."""
title, message = msg.summary(
"short.pdf", pages_used=95, pages_limit=100, pages_to_add=10
)
assert title == "Page limit exceeded: short.pdf"
assert message == (
"This document has ~10 page(s) but you've used 95/100 pages. "
"Upgrade to process more documents."
)
def test_summary_truncates_long_name():
"""A long document name is truncated in the title."""
title, _ = msg.summary("a" * 50, pages_used=1, pages_limit=2, pages_to_add=1)
assert title == f"Page limit exceeded: {'a' * 40}..."

View file

@ -33,8 +33,7 @@ def _disable_otel(monkeypatch: pytest.MonkeyPatch):
("generate_video_presentation", "generate"), ("generate_video_presentation", "generate"),
("generate_content_podcast", "generate"), ("generate_content_podcast", "generate"),
("cleanup_stale_indexing_notifications", "cleanup"), ("cleanup_stale_indexing_notifications", "cleanup"),
("reconcile_pending_stripe_page_purchases", "reconcile"), ("reconcile_pending_stripe_credit_purchases", "reconcile"),
("reconcile_pending_stripe_token_purchases", "reconcile"),
("check_periodic_schedules", "check"), ("check_periodic_schedules", "check"),
("ai_sort_search_space", "ai"), ("ai_sort_search_space", "ai"),
("index_notion_pages", "index"), ("index_notion_pages", "index"),

View file

@ -90,7 +90,7 @@ async def test_auto_first_turn_pins_one_model(monkeypatch):
return _FakeQuotaResult(allowed=True) return _FakeQuotaResult(allowed=True)
monkeypatch.setattr( monkeypatch.setattr(
"app.services.auto_model_pin_service.TokenQuotaService.premium_get_usage", "app.services.auto_model_pin_service.TokenQuotaService.credit_get_usage",
_allowed, _allowed,
) )
@ -138,7 +138,7 @@ async def test_premium_eligible_auto_prefers_premium_over_free(monkeypatch):
return _FakeQuotaResult(allowed=True) return _FakeQuotaResult(allowed=True)
monkeypatch.setattr( monkeypatch.setattr(
"app.services.auto_model_pin_service.TokenQuotaService.premium_get_usage", "app.services.auto_model_pin_service.TokenQuotaService.credit_get_usage",
_allowed, _allowed,
) )
@ -196,7 +196,7 @@ async def test_premium_eligible_auto_prefers_azure_gpt_5_4(monkeypatch):
return _FakeQuotaResult(allowed=True) return _FakeQuotaResult(allowed=True)
monkeypatch.setattr( monkeypatch.setattr(
"app.services.auto_model_pin_service.TokenQuotaService.premium_get_usage", "app.services.auto_model_pin_service.TokenQuotaService.credit_get_usage",
_allowed, _allowed,
) )
@ -232,11 +232,11 @@ async def test_next_turn_reuses_existing_pin(monkeypatch):
async def _must_not_call(*_args, **_kwargs): async def _must_not_call(*_args, **_kwargs):
raise AssertionError( raise AssertionError(
"premium_get_usage should not be called for valid pin reuse" "credit_get_usage should not be called for valid pin reuse"
) )
monkeypatch.setattr( monkeypatch.setattr(
"app.services.auto_model_pin_service.TokenQuotaService.premium_get_usage", "app.services.auto_model_pin_service.TokenQuotaService.credit_get_usage",
_must_not_call, _must_not_call,
) )
@ -275,7 +275,7 @@ async def test_premium_eligible_auto_can_pin_premium(monkeypatch):
return _FakeQuotaResult(allowed=True) return _FakeQuotaResult(allowed=True)
monkeypatch.setattr( monkeypatch.setattr(
"app.services.auto_model_pin_service.TokenQuotaService.premium_get_usage", "app.services.auto_model_pin_service.TokenQuotaService.credit_get_usage",
_allowed, _allowed,
) )
@ -320,7 +320,7 @@ async def test_premium_ineligible_auto_pins_free_only(monkeypatch):
return _FakeQuotaResult(allowed=False) return _FakeQuotaResult(allowed=False)
monkeypatch.setattr( monkeypatch.setattr(
"app.services.auto_model_pin_service.TokenQuotaService.premium_get_usage", "app.services.auto_model_pin_service.TokenQuotaService.credit_get_usage",
_blocked, _blocked,
) )
@ -365,7 +365,7 @@ async def test_pinned_premium_stays_premium_after_quota_exhaustion(monkeypatch):
return _FakeQuotaResult(allowed=False) return _FakeQuotaResult(allowed=False)
monkeypatch.setattr( monkeypatch.setattr(
"app.services.auto_model_pin_service.TokenQuotaService.premium_get_usage", "app.services.auto_model_pin_service.TokenQuotaService.credit_get_usage",
_blocked, _blocked,
) )
@ -410,7 +410,7 @@ async def test_force_repin_free_switches_auto_premium_pin_to_free(monkeypatch):
return _FakeQuotaResult(allowed=False) return _FakeQuotaResult(allowed=False)
monkeypatch.setattr( monkeypatch.setattr(
"app.services.auto_model_pin_service.TokenQuotaService.premium_get_usage", "app.services.auto_model_pin_service.TokenQuotaService.credit_get_usage",
_blocked, _blocked,
) )
@ -470,7 +470,7 @@ async def test_invalid_pinned_config_repairs_with_new_pin(monkeypatch):
return _FakeQuotaResult(allowed=False) return _FakeQuotaResult(allowed=False)
monkeypatch.setattr( monkeypatch.setattr(
"app.services.auto_model_pin_service.TokenQuotaService.premium_get_usage", "app.services.auto_model_pin_service.TokenQuotaService.credit_get_usage",
_blocked, _blocked,
) )
@ -529,7 +529,7 @@ async def test_health_gated_config_is_excluded_from_selection(monkeypatch):
return _FakeQuotaResult(allowed=False) return _FakeQuotaResult(allowed=False)
monkeypatch.setattr( monkeypatch.setattr(
"app.services.auto_model_pin_service.TokenQuotaService.premium_get_usage", "app.services.auto_model_pin_service.TokenQuotaService.credit_get_usage",
_blocked, _blocked,
) )
@ -581,7 +581,7 @@ async def test_tier_a_locks_first_premium_user_skips_or(monkeypatch):
return _FakeQuotaResult(allowed=True) return _FakeQuotaResult(allowed=True)
monkeypatch.setattr( monkeypatch.setattr(
"app.services.auto_model_pin_service.TokenQuotaService.premium_get_usage", "app.services.auto_model_pin_service.TokenQuotaService.credit_get_usage",
_allowed, _allowed,
) )
@ -633,7 +633,7 @@ async def test_tier_a_falls_through_to_or_when_a_pool_empty_for_user(monkeypatch
return _FakeQuotaResult(allowed=False) return _FakeQuotaResult(allowed=False)
monkeypatch.setattr( monkeypatch.setattr(
"app.services.auto_model_pin_service.TokenQuotaService.premium_get_usage", "app.services.auto_model_pin_service.TokenQuotaService.credit_get_usage",
_blocked, _blocked,
) )
@ -686,7 +686,7 @@ async def test_top_k_picks_only_high_score_models(monkeypatch):
return _FakeQuotaResult(allowed=True) return _FakeQuotaResult(allowed=True)
monkeypatch.setattr( monkeypatch.setattr(
"app.services.auto_model_pin_service.TokenQuotaService.premium_get_usage", "app.services.auto_model_pin_service.TokenQuotaService.credit_get_usage",
_allowed, _allowed,
) )
@ -754,7 +754,7 @@ async def test_pin_reuse_survives_health_gating_for_existing_pin(monkeypatch):
return _FakeQuotaResult(allowed=True) return _FakeQuotaResult(allowed=True)
monkeypatch.setattr( monkeypatch.setattr(
"app.services.auto_model_pin_service.TokenQuotaService.premium_get_usage", "app.services.auto_model_pin_service.TokenQuotaService.credit_get_usage",
_allowed, _allowed,
) )
@ -803,10 +803,10 @@ async def test_pin_reuse_regression_existing_healthy_pin(monkeypatch):
) )
async def _must_not_call(*_args, **_kwargs): async def _must_not_call(*_args, **_kwargs):
raise AssertionError("premium_get_usage should not run on pin reuse") raise AssertionError("credit_get_usage should not run on pin reuse")
monkeypatch.setattr( monkeypatch.setattr(
"app.services.auto_model_pin_service.TokenQuotaService.premium_get_usage", "app.services.auto_model_pin_service.TokenQuotaService.credit_get_usage",
_must_not_call, _must_not_call,
) )
@ -864,7 +864,7 @@ async def test_runtime_cooled_down_pin_is_not_reused(monkeypatch):
return _FakeQuotaResult(allowed=False) return _FakeQuotaResult(allowed=False)
monkeypatch.setattr( monkeypatch.setattr(
"app.services.auto_model_pin_service.TokenQuotaService.premium_get_usage", "app.services.auto_model_pin_service.TokenQuotaService.credit_get_usage",
_blocked, _blocked,
) )
@ -904,10 +904,10 @@ async def test_clearing_runtime_cooldown_restores_pin_reuse(monkeypatch):
) )
async def _must_not_call(*_args, **_kwargs): async def _must_not_call(*_args, **_kwargs):
raise AssertionError("premium_get_usage should not run on healthy pin reuse") raise AssertionError("credit_get_usage should not run on healthy pin reuse")
monkeypatch.setattr( monkeypatch.setattr(
"app.services.auto_model_pin_service.TokenQuotaService.premium_get_usage", "app.services.auto_model_pin_service.TokenQuotaService.credit_get_usage",
_must_not_call, _must_not_call,
) )
@ -962,7 +962,7 @@ async def test_auto_pin_repin_excludes_previous_config_on_runtime_retry(monkeypa
return _FakeQuotaResult(allowed=False) return _FakeQuotaResult(allowed=False)
monkeypatch.setattr( monkeypatch.setattr(
"app.services.auto_model_pin_service.TokenQuotaService.premium_get_usage", "app.services.auto_model_pin_service.TokenQuotaService.credit_get_usage",
_blocked, _blocked,
) )

View file

@ -114,7 +114,7 @@ async def test_image_turn_filters_out_text_only_candidates(monkeypatch):
[_text_only_cfg(-1), _vision_cfg(-2)], [_text_only_cfg(-1), _vision_cfg(-2)],
) )
monkeypatch.setattr( monkeypatch.setattr(
"app.services.auto_model_pin_service.TokenQuotaService.premium_get_usage", "app.services.auto_model_pin_service.TokenQuotaService.credit_get_usage",
_premium_allowed, _premium_allowed,
) )
@ -146,7 +146,7 @@ async def test_image_turn_force_repins_stale_text_only_pin(monkeypatch):
[_text_only_cfg(-1), _vision_cfg(-2)], [_text_only_cfg(-1), _vision_cfg(-2)],
) )
monkeypatch.setattr( monkeypatch.setattr(
"app.services.auto_model_pin_service.TokenQuotaService.premium_get_usage", "app.services.auto_model_pin_service.TokenQuotaService.credit_get_usage",
_premium_allowed, _premium_allowed,
) )
@ -178,7 +178,7 @@ async def test_image_turn_reuses_existing_vision_pin(monkeypatch):
[_text_only_cfg(-1), _vision_cfg(-2), _vision_cfg(-3, quality=70)], [_text_only_cfg(-1), _vision_cfg(-2), _vision_cfg(-3, quality=70)],
) )
monkeypatch.setattr( monkeypatch.setattr(
"app.services.auto_model_pin_service.TokenQuotaService.premium_get_usage", "app.services.auto_model_pin_service.TokenQuotaService.credit_get_usage",
_premium_allowed, _premium_allowed,
) )
@ -209,7 +209,7 @@ async def test_image_turn_with_no_vision_candidates_raises(monkeypatch):
[_text_only_cfg(-1), _text_only_cfg(-2)], [_text_only_cfg(-1), _text_only_cfg(-2)],
) )
monkeypatch.setattr( monkeypatch.setattr(
"app.services.auto_model_pin_service.TokenQuotaService.premium_get_usage", "app.services.auto_model_pin_service.TokenQuotaService.credit_get_usage",
_premium_allowed, _premium_allowed,
) )
@ -237,7 +237,7 @@ async def test_non_image_turn_keeps_text_only_in_pool(monkeypatch):
[_text_only_cfg(-1)], [_text_only_cfg(-1)],
) )
monkeypatch.setattr( monkeypatch.setattr(
"app.services.auto_model_pin_service.TokenQuotaService.premium_get_usage", "app.services.auto_model_pin_service.TokenQuotaService.credit_get_usage",
_premium_allowed, _premium_allowed,
) )
@ -271,7 +271,7 @@ async def test_image_turn_unannotated_cfg_resolves_via_helper(monkeypatch):
} }
monkeypatch.setattr(config, "GLOBAL_LLM_CONFIGS", [cfg_unannotated_vision]) monkeypatch.setattr(config, "GLOBAL_LLM_CONFIGS", [cfg_unannotated_vision])
monkeypatch.setattr( monkeypatch.setattr(
"app.services.auto_model_pin_service.TokenQuotaService.premium_get_usage", "app.services.auto_model_pin_service.TokenQuotaService.credit_get_usage",
_premium_allowed, _premium_allowed,
) )

View file

@ -38,11 +38,13 @@ class _FakeQuotaResult:
used: int = 0, used: int = 0,
limit: int = 5_000_000, limit: int = 5_000_000,
remaining: int = 5_000_000, remaining: int = 5_000_000,
balance: int = 5_000_000,
) -> None: ) -> None:
self.allowed = allowed self.allowed = allowed
self.used = used self.used = used
self.limit = limit self.limit = limit
self.remaining = remaining self.remaining = remaining
self.balance = balance
class _FakeSession: class _FakeSession:
@ -118,17 +120,17 @@ def _patch_isolation_layer(
return object() return object()
monkeypatch.setattr( monkeypatch.setattr(
"app.services.billable_calls.TokenQuotaService.premium_reserve", "app.services.billable_calls.TokenQuotaService.credit_reserve",
_fake_reserve, _fake_reserve,
raising=False, raising=False,
) )
monkeypatch.setattr( monkeypatch.setattr(
"app.services.billable_calls.TokenQuotaService.premium_finalize", "app.services.billable_calls.TokenQuotaService.credit_finalize",
_fake_finalize, _fake_finalize,
raising=False, raising=False,
) )
monkeypatch.setattr( monkeypatch.setattr(
"app.services.billable_calls.TokenQuotaService.premium_release", "app.services.billable_calls.TokenQuotaService.credit_release",
_fake_release, _fake_release,
raising=False, raising=False,
) )
@ -201,9 +203,7 @@ async def test_premium_reserve_denied_raises_quota_insufficient(monkeypatch):
spies = _patch_isolation_layer( spies = _patch_isolation_layer(
monkeypatch, monkeypatch,
reserve_result=_FakeQuotaResult( reserve_result=_FakeQuotaResult(allowed=False, balance=0, remaining=0),
allowed=False, used=5_000_000, limit=5_000_000, remaining=0
),
) )
user_id = uuid4() user_id = uuid4()
@ -220,8 +220,7 @@ async def test_premium_reserve_denied_raises_quota_insufficient(monkeypatch):
err = exc_info.value err = exc_info.value
assert err.usage_type == "image_generation" assert err.usage_type == "image_generation"
assert err.used_micros == 5_000_000 assert err.balance_micros == 0
assert err.limit_micros == 5_000_000
assert err.remaining_micros == 0 assert err.remaining_micros == 0
# Reserve was attempted, but no finalize/release on a denied reserve # Reserve was attempted, but no finalize/release on a denied reserve
# — the reservation never actually held credit. # — the reservation never actually held credit.
@ -532,7 +531,7 @@ async def test_premium_video_denial_raises_quota_insufficient(monkeypatch):
spies = _patch_isolation_layer( spies = _patch_isolation_layer(
monkeypatch, monkeypatch,
reserve_result=_FakeQuotaResult( reserve_result=_FakeQuotaResult(
allowed=False, used=4_500_000, limit=5_000_000, remaining=500_000 allowed=False, balance=500_000, remaining=500_000
), ),
) )
user_id = uuid4() user_id = uuid4()
@ -552,6 +551,7 @@ async def test_premium_video_denial_raises_quota_insufficient(monkeypatch):
err = exc_info.value err = exc_info.value
assert err.usage_type == "video_presentation_generation" assert err.usage_type == "video_presentation_generation"
assert err.balance_micros == 500_000
assert err.remaining_micros == 500_000 assert err.remaining_micros == 500_000
assert spies["reserve"][0]["reserve_micros"] == 1_000_000 assert spies["reserve"][0]["reserve_micros"] == 1_000_000
assert spies["finalize"] == [] assert spies["finalize"] == []

View file

@ -1,48 +1,15 @@
"use client"; "use client";
import { useState } from "react"; import { AutoReloadSettings } from "@/components/settings/auto-reload-settings";
import { BuyPagesContent } from "@/components/settings/buy-pages-content"; import { BuyCreditsContent } from "@/components/settings/buy-credits-content";
import { BuyTokensContent } from "@/components/settings/buy-tokens-content";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
const TABS = [
{ id: "pages", label: "Pages" },
{ id: "tokens", label: "Premium Credit" },
] as const;
type TabId = (typeof TABS)[number]["id"];
export default function BuyMorePage() { export default function BuyMorePage() {
const [activeTab, setActiveTab] = useState<TabId>("pages");
return ( return (
<div className="w-full select-none"> <div className="flex min-h-[37rem] w-full select-none items-center justify-center py-8">
<Tabs <div className="w-full max-w-md space-y-8">
value={activeTab} <BuyCreditsContent />
onValueChange={(value) => { <AutoReloadSettings />
setActiveTab(value as TabId); </div>
}}
className="relative min-h-[37rem] w-full"
>
<TabsList className="absolute top-20 left-1/2 -translate-x-1/2 rounded-xl bg-accent p-1">
{TABS.map((tab) => (
<TabsTrigger
key={tab.id}
value={tab.id}
className="h-8 rounded-lg px-4 text-sm font-semibold text-accent-foreground transition-colors hover:bg-transparent hover:text-white data-[state=active]:bg-[#4a4a4a] data-[state=active]:text-white data-[state=active]:shadow-none"
>
{tab.label}
</TabsTrigger>
))}
</TabsList>
<TabsContent value="pages" className="mt-0 flex min-h-[37rem] items-center pt-14">
<BuyPagesContent />
</TabsContent>
<TabsContent value="tokens" className="mt-0 flex min-h-[37rem] items-center pt-14">
<BuyTokensContent />
</TabsContent>
</Tabs>
</div> </div>
); );
} }

View file

@ -0,0 +1,11 @@
"use client";
import { EarnCreditsContent } from "@/components/settings/earn-credits-content";
export default function EarnCreditsPage() {
return (
<div className="w-full select-none space-y-6">
<EarnCreditsContent />
</div>
);
}

View file

@ -1,11 +1,18 @@
"use client"; "use client";
import { MorePagesContent } from "@/components/settings/more-pages-content"; import { useParams, useRouter } from "next/navigation";
import { useEffect } from "react";
// Legacy route kept as a redirect: older "insufficient credits" notifications
// and bookmarks may still point at /more-pages.
export default function MorePagesPage() { export default function MorePagesPage() {
return ( const router = useRouter();
<div className="w-full select-none space-y-6"> const params = useParams();
<MorePagesContent /> const searchSpaceId = params?.search_space_id ?? "";
</div>
); useEffect(() => {
router.replace(`/dashboard/${searchSpaceId}/earn-credits`);
}, [router, searchSpaceId]);
return null;
} }

View file

@ -112,9 +112,7 @@ export default function PurchaseSuccessPage() {
{state.kind === "still_pending" && {state.kind === "still_pending" &&
"Your payment is still being processed by your bank. We'll apply your purchase as soon as it clears — usually within a few minutes. You can safely close this page."} "Your payment is still being processed by your bank. We'll apply your purchase as soon as it clears — usually within a few minutes. You can safely close this page."}
{state.kind === "completed" && {state.kind === "completed" &&
(state.data.purchase_type === "page_packs" `Added ${formatCredit(state.data.credit_micros_granted ?? 0)} of credit to your account.`}
? `Added ${formatNumber(state.data.pages_granted ?? 0)} pages to your account.`
: `Added ${formatCredit(state.data.premium_credit_micros_granted ?? 0)} of premium credit to your account.`)}
{state.kind === "failed" && {state.kind === "failed" &&
"Stripe reported the checkout as failed or expired. Your card was not charged."} "Stripe reported the checkout as failed or expired. Your card was not charged."}
{state.kind === "error" && {state.kind === "error" &&
@ -123,18 +121,9 @@ export default function PurchaseSuccessPage() {
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent className="space-y-3 text-center"> <CardContent className="space-y-3 text-center">
{state.kind === "completed" && state.data.purchase_type === "page_packs" && ( {state.kind === "completed" && (
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
New balance: {formatNumber(state.data.pages_limit ?? 0)} total pages New credit balance: {formatCredit(state.data.credit_micros_balance ?? 0)}
{typeof state.data.pages_used === "number"
? ` (${formatNumber((state.data.pages_limit ?? 0) - state.data.pages_used)} remaining)`
: ""}
</p>
)}
{state.kind === "completed" && state.data.purchase_type === "premium_tokens" && (
<p className="text-sm text-muted-foreground">
New premium credit balance:{" "}
{formatCredit(state.data.premium_credit_micros_limit ?? 0)}
</p> </p>
)} )}
{state.kind === "error" && ( {state.kind === "error" && (
@ -146,7 +135,7 @@ export default function PurchaseSuccessPage() {
<Link href={`/dashboard/${searchSpaceId}/new-chat`}>Back to Dashboard</Link> <Link href={`/dashboard/${searchSpaceId}/new-chat`}>Back to Dashboard</Link>
</Button> </Button>
<Button asChild variant="outline" className="w-full"> <Button asChild variant="outline" className="w-full">
<Link href={`/dashboard/${searchSpaceId}/buy-more`}>Buy More</Link> <Link href={`/dashboard/${searchSpaceId}/buy-more`}>Buy credits</Link>
</Button> </Button>
</CardFooter> </CardFooter>
</Card> </Card>
@ -154,10 +143,6 @@ export default function PurchaseSuccessPage() {
); );
} }
function formatNumber(n: number): string {
return new Intl.NumberFormat("en-US").format(n);
}
function formatCredit(micros: number): string { function formatCredit(micros: number): string {
const dollars = micros / 1_000_000; const dollars = micros / 1_000_000;
return new Intl.NumberFormat("en-US", { return new Intl.NumberFormat("en-US", {

View file

@ -14,24 +14,24 @@ import {
TableRow, TableRow,
} from "@/components/ui/table"; } from "@/components/ui/table";
import type { import type {
CreditPurchase,
PagePurchase, PagePurchase,
PagePurchaseStatus, PurchaseStatus,
TokenPurchase,
} from "@/contracts/types/stripe.types"; } from "@/contracts/types/stripe.types";
import { stripeApiService } from "@/lib/apis/stripe-api.service"; import { stripeApiService } from "@/lib/apis/stripe-api.service";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
type PurchaseKind = "pages" | "tokens"; type PurchaseKind = "pages" | "credits";
type UnifiedPurchase = { type UnifiedPurchase = {
id: string; id: string;
kind: PurchaseKind; kind: PurchaseKind;
created_at: string; created_at: string;
status: PagePurchaseStatus; status: PurchaseStatus;
/** /**
* Granted units. Interpretation depends on ``kind``: * Granted units. Interpretation depends on ``kind``:
* - ``"pages"`` integer number of indexed pages. * - ``"pages"`` integer number of indexed pages (legacy history).
* - ``"tokens"`` integer micro-USD of credit (1_000_000 = $1.00). * - ``"credits"`` integer micro-USD of credit (1_000_000 = $1.00).
* The ``Granted`` column formats accordingly. * The ``Granted`` column formats accordingly.
*/ */
granted: number; granted: number;
@ -39,7 +39,7 @@ type UnifiedPurchase = {
currency: string | null; currency: string | null;
}; };
const STATUS_STYLES: Record<PagePurchaseStatus, { label: string; className: string }> = { const STATUS_STYLES: Record<PurchaseStatus, { label: string; className: string }> = {
completed: { completed: {
label: "Completed", label: "Completed",
className: "bg-emerald-600 text-white border-transparent hover:bg-emerald-600", className: "bg-emerald-600 text-white border-transparent hover:bg-emerald-600",
@ -63,8 +63,8 @@ const KIND_META: Record<
icon: FileText, icon: FileText,
iconClass: "text-sky-500", iconClass: "text-sky-500",
}, },
tokens: { credits: {
label: "Premium Credit", label: "Credits",
icon: Coins, icon: Coins,
iconClass: "text-amber-500", iconClass: "text-amber-500",
}, },
@ -97,10 +97,10 @@ function normalizePagePurchase(p: PagePurchase): UnifiedPurchase {
}; };
} }
function normalizeTokenPurchase(p: TokenPurchase): UnifiedPurchase { function normalizeCreditPurchase(p: CreditPurchase): UnifiedPurchase {
return { return {
id: p.id, id: p.id,
kind: "tokens", kind: "credits",
created_at: p.created_at, created_at: p.created_at,
status: p.status, status: p.status,
granted: p.credit_micros_granted, granted: p.credit_micros_granted,
@ -110,10 +110,10 @@ function normalizeTokenPurchase(p: TokenPurchase): UnifiedPurchase {
} }
function formatGranted(p: UnifiedPurchase): string { function formatGranted(p: UnifiedPurchase): string {
if (p.kind === "tokens") { if (p.kind === "credits") {
const dollars = p.granted / 1_000_000; const dollars = p.granted / 1_000_000;
// Premium credit packs are always whole dollars at the moment, but // Credit packs are always whole dollars at the moment, but future
// future fractional grants (refunds, partial top-ups) shouldn't // fractional grants (refunds, partial top-ups, auto-reload) shouldn't
// silently round to "$0". // silently round to "$0".
if (dollars >= 1) return `$${dollars.toFixed(2)} of credit`; if (dollars >= 1) return `$${dollars.toFixed(2)} of credit`;
if (dollars > 0) return `$${dollars.toFixed(3)} of credit`; if (dollars > 0) return `$${dollars.toFixed(3)} of credit`;
@ -127,26 +127,26 @@ export function PurchaseHistoryContent() {
queries: [ queries: [
{ {
queryKey: ["stripe-purchases"], queryKey: ["stripe-purchases"],
queryFn: () => stripeApiService.getPurchases(), queryFn: () => stripeApiService.getPagePurchases(),
}, },
{ {
queryKey: ["stripe-token-purchases"], queryKey: ["stripe-credit-purchases"],
queryFn: () => stripeApiService.getTokenPurchases(), queryFn: () => stripeApiService.getCreditPurchases(),
}, },
], ],
}); });
const [pagesQuery, tokensQuery] = results; const [pagesQuery, creditsQuery] = results;
const isLoading = pagesQuery.isLoading || tokensQuery.isLoading; const isLoading = pagesQuery.isLoading || creditsQuery.isLoading;
const purchases = useMemo<UnifiedPurchase[]>(() => { const purchases = useMemo<UnifiedPurchase[]>(() => {
const pagePurchases = pagesQuery.data?.purchases ?? []; const pagePurchases = pagesQuery.data?.purchases ?? [];
const tokenPurchases = tokensQuery.data?.purchases ?? []; const creditPurchases = creditsQuery.data?.purchases ?? [];
return [ return [
...pagePurchases.map(normalizePagePurchase), ...pagePurchases.map(normalizePagePurchase),
...tokenPurchases.map(normalizeTokenPurchase), ...creditPurchases.map(normalizeCreditPurchase),
].sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime()); ].sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime());
}, [pagesQuery.data, tokensQuery.data]); }, [pagesQuery.data, creditsQuery.data]);
if (isLoading) { if (isLoading) {
return ( return (
@ -162,7 +162,7 @@ export function PurchaseHistoryContent() {
<ReceiptText className="h-8 w-8 text-muted-foreground" /> <ReceiptText className="h-8 w-8 text-muted-foreground" />
<p className="text-sm font-medium">No purchases yet</p> <p className="text-sm font-medium">No purchases yet</p>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
Your page and premium credit purchases will appear here after checkout. Your credit purchases will appear here after checkout.
</p> </p>
</div> </div>
); );

View file

@ -1,5 +1,11 @@
import { AutoReloadSettings } from "@/components/settings/auto-reload-settings";
import { PurchaseHistoryContent } from "../components/PurchaseHistoryContent"; import { PurchaseHistoryContent } from "../components/PurchaseHistoryContent";
export default function Page() { export default function Page() {
return <PurchaseHistoryContent />; return (
<div className="space-y-6">
<AutoReloadSettings />
<PurchaseHistoryContent />
</div>
);
} }

View file

@ -12,6 +12,7 @@ export type {
export { export {
ChatListItem, ChatListItem,
CreateSearchSpaceDialog, CreateSearchSpaceDialog,
CreditBalanceDisplay,
Header, Header,
IconRail, IconRail,
LayoutShell, LayoutShell,
@ -19,7 +20,6 @@ export {
MobileSidebarTrigger, MobileSidebarTrigger,
NavIcon, NavIcon,
NavSection, NavSection,
PageUsageDisplay,
SearchSpaceAvatar, SearchSpaceAvatar,
Sidebar, Sidebar,
SidebarCollapseButton, SidebarCollapseButton,

View file

@ -186,40 +186,40 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid
setStatusInboxItems(statusInbox.inboxItems); setStatusInboxItems(statusInbox.inboxItems);
}, [statusInbox.inboxItems, setStatusInboxItems]); }, [statusInbox.inboxItems, setStatusInboxItems]);
// Track seen notification IDs to detect new page_limit_exceeded notifications // Track seen notification IDs to detect new insufficient_credits notifications
const seenPageLimitNotifications = useRef<Set<number>>(new Set()); const seenCreditNotifications = useRef<Set<number>>(new Set());
const isInitialLoad = useRef(true); const isInitialLoad = useRef(true);
// Effect to show toast for new page_limit_exceeded notifications // Effect to show toast for new insufficient_credits notifications
useEffect(() => { useEffect(() => {
if (statusInbox.loading) return; if (statusInbox.loading) return;
const pageLimitNotifications = statusInbox.inboxItems.filter( const creditNotifications = statusInbox.inboxItems.filter(
(item) => item.type === "page_limit_exceeded" (item) => item.type === "insufficient_credits"
); );
if (isInitialLoad.current) { if (isInitialLoad.current) {
for (const notification of pageLimitNotifications) { for (const notification of creditNotifications) {
seenPageLimitNotifications.current.add(notification.id); seenCreditNotifications.current.add(notification.id);
} }
isInitialLoad.current = false; isInitialLoad.current = false;
return; return;
} }
const newNotifications = pageLimitNotifications.filter( const newNotifications = creditNotifications.filter(
(notification) => !seenPageLimitNotifications.current.has(notification.id) (notification) => !seenCreditNotifications.current.has(notification.id)
); );
for (const notification of newNotifications) { for (const notification of newNotifications) {
seenPageLimitNotifications.current.add(notification.id); seenCreditNotifications.current.add(notification.id);
toast.error(notification.title, { toast.error(notification.title, {
description: notification.message, description: notification.message,
duration: 8000, duration: 8000,
icon: <AlertTriangle className="h-5 w-5 text-amber-500" />, icon: <AlertTriangle className="h-5 w-5 text-amber-500" />,
action: { action: {
label: "Get More Pages", label: "Buy credits",
onClick: () => router.push(`/dashboard/${searchSpaceId}/more-pages`), onClick: () => router.push(`/dashboard/${searchSpaceId}/buy-more`),
}, },
}); });
} }
@ -696,6 +696,7 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid
const isAutomationsPage = pathname?.includes("/automations") === true; const isAutomationsPage = pathname?.includes("/automations") === true;
const useWorkspacePanel = const useWorkspacePanel =
pathname?.endsWith("/buy-more") === true || pathname?.endsWith("/buy-more") === true ||
pathname?.endsWith("/earn-credits") === true ||
pathname?.endsWith("/more-pages") === true || pathname?.endsWith("/more-pages") === true ||
isUserSettingsPage || isUserSettingsPage ||
isSearchSpaceSettingsPage || isSearchSpaceSettingsPage ||

View file

@ -74,11 +74,6 @@ export interface ChatsSectionProps {
searchSpaceId?: string; searchSpaceId?: string;
} }
export interface PageUsageDisplayProps {
pagesUsed: number;
pagesLimit: number;
}
export interface SidebarUserProfileProps { export interface SidebarUserProfileProps {
user: User; user: User;
searchSpaceId?: string; searchSpaceId?: string;

View file

@ -4,10 +4,10 @@ export { IconRail, NavIcon, SearchSpaceAvatar } from "./icon-rail";
export { LayoutShell } from "./shell"; export { LayoutShell } from "./shell";
export { export {
ChatListItem, ChatListItem,
CreditBalanceDisplay,
MobileSidebar, MobileSidebar,
MobileSidebarTrigger, MobileSidebarTrigger,
NavSection, NavSection,
PageUsageDisplay,
Sidebar, Sidebar,
SidebarCollapseButton, SidebarCollapseButton,
SidebarHeader, SidebarHeader,

View file

@ -1,15 +0,0 @@
"use client";
import { useQuery } from "@rocicorp/zero/react";
import { useIsAnonymous } from "@/contexts/anonymous-mode";
import { queries } from "@/zero/queries";
import { PageUsageDisplay } from "./PageUsageDisplay";
export function AuthenticatedPageUsageDisplay() {
const isAnonymous = useIsAnonymous();
const [me] = useQuery(queries.user.me({}));
if (isAnonymous || !me) return null;
return <PageUsageDisplay pagesUsed={me.pagesUsed} pagesLimit={me.pagesLimit} />;
}

View file

@ -0,0 +1,55 @@
"use client";
import { useQuery } from "@rocicorp/zero/react";
import { useIsAnonymous } from "@/contexts/anonymous-mode";
import { cn } from "@/lib/utils";
import { queries } from "@/zero/queries";
// Show the low-balance warning state once the wallet drops below $0.50.
const LOW_BALANCE_WARNING_MICROS = 500_000;
function formatUsd(micros: number): string {
// Clamp at $0.00 — the balance can dip slightly negative when the actual
// cost of a job exceeds the pre-charge estimate.
const dollars = Math.max(0, micros) / 1_000_000;
if (dollars >= 100) return `$${dollars.toFixed(0)}`;
if (dollars >= 1) return `$${dollars.toFixed(2)}`;
// Sub-dollar balances need extra precision so the user can still tell what
// is left ("$0.042 of credit") instead of rounding to "$0.00".
if (dollars > 0) return `$${dollars.toFixed(3)}`;
return "$0.00";
}
/**
* Unified credit-wallet balance shown in the sidebar.
*
* The single ``creditMicrosBalance`` replaces the former page-limit and
* premium-credit meters. Values come from Zero (live-replicated from Postgres)
* as integer micro-USD (1_000_000 == $1.00). A low-balance warning highlights
* the amount when it falls below $0.50 so the user knows to top up or enable
* auto-reload.
*/
export function CreditBalanceDisplay() {
const isAnonymous = useIsAnonymous();
const [me] = useQuery(queries.user.me({}));
if (isAnonymous || !me) return null;
const balanceMicros = me.creditMicrosBalance ?? 0;
const isLow = balanceMicros < LOW_BALANCE_WARNING_MICROS;
return (
<div className="flex items-center justify-between text-xs">
<span className="text-muted-foreground">Credits</span>
<span
className={cn(
"font-medium tabular-nums",
isLow ? "text-amber-600 dark:text-amber-500" : "text-foreground"
)}
title={isLow ? "Low balance — buy credits or enable auto-reload" : undefined}
>
{formatUsd(balanceMicros)}
</span>
</div>
);
}

View file

@ -49,7 +49,7 @@ import {
isConnectorIndexingMetadata, isConnectorIndexingMetadata,
isDocumentProcessingMetadata, isDocumentProcessingMetadata,
isNewMentionMetadata, isNewMentionMetadata,
isPageLimitExceededMetadata, isInsufficientCreditsMetadata,
} from "@/contracts/types/inbox.types"; } from "@/contracts/types/inbox.types";
import { useDebouncedValue } from "@/hooks/use-debounced-value"; import { useDebouncedValue } from "@/hooks/use-debounced-value";
import type { InboxItem } from "@/hooks/use-inbox"; import type { InboxItem } from "@/hooks/use-inbox";
@ -291,7 +291,7 @@ export function InboxSidebarContent({
(item: InboxItem): boolean => { (item: InboxItem): boolean => {
if (activeFilter === "unread") return !item.read; if (activeFilter === "unread") return !item.read;
if (activeFilter === "errors") { if (activeFilter === "errors") {
if (item.type === "page_limit_exceeded") return true; if (item.type === "insufficient_credits") return true;
const meta = item.metadata as Record<string, unknown> | undefined; const meta = item.metadata as Record<string, unknown> | undefined;
return typeof meta?.status === "string" && meta.status === "failed"; return typeof meta?.status === "string" && meta.status === "failed";
} }
@ -397,8 +397,8 @@ export function InboxSidebarContent({
router.push(url); router.push(url);
} }
} }
} else if (item.type === "page_limit_exceeded") { } else if (item.type === "insufficient_credits") {
if (isPageLimitExceededMetadata(item.metadata)) { if (isInsufficientCreditsMetadata(item.metadata)) {
const actionUrl = item.metadata.action_url; const actionUrl = item.metadata.action_url;
if (actionUrl) { if (actionUrl) {
onOpenChange(false); onOpenChange(false);
@ -470,7 +470,7 @@ export function InboxSidebarContent({
); );
} }
if (item.type === "page_limit_exceeded") { if (item.type === "insufficient_credits") {
return ( return (
<div className="h-8 w-8 flex items-center justify-center rounded-full bg-amber-500/10"> <div className="h-8 w-8 flex items-center justify-center rounded-full bg-amber-500/10">
<AlertTriangle className="h-4 w-4 text-amber-500" /> <AlertTriangle className="h-4 w-4 text-amber-500" />

View file

@ -1,24 +0,0 @@
"use client";
import { Progress } from "@/components/ui/progress";
interface PageUsageDisplayProps {
pagesUsed: number;
pagesLimit: number;
}
export function PageUsageDisplay({ pagesUsed, pagesLimit }: PageUsageDisplayProps) {
const usagePercentage = (pagesUsed / pagesLimit) * 100;
return (
<div className="space-y-1.5">
<div className="flex justify-between items-center text-xs">
<span className="text-muted-foreground">
{pagesUsed.toLocaleString()} / {pagesLimit.toLocaleString()} pages
</span>
<span className="font-medium">{usagePercentage.toFixed(0)}%</span>
</div>
<Progress value={usagePercentage} className="h-1.5" />
</div>
);
}

View file

@ -1,49 +0,0 @@
"use client";
import { useQuery } from "@rocicorp/zero/react";
import { Progress } from "@/components/ui/progress";
import { useIsAnonymous } from "@/contexts/anonymous-mode";
import { queries } from "@/zero/queries";
/**
* Premium credit balance shown in the sidebar.
*
* Values come from Zero (live-replicated from Postgres) and are stored as
* integer micro-USD (1_000_000 == $1.00). We render in dollars because
* users top up at $1/pack and the credit gets debited at actual provider
* cost.
*/
export function PremiumTokenUsageDisplay() {
const isAnonymous = useIsAnonymous();
const [me] = useQuery(queries.user.me({}));
if (isAnonymous || !me) return null;
const usagePercentage = Math.min(
(me.premiumCreditMicrosUsed / Math.max(me.premiumCreditMicrosLimit, 1)) * 100,
100
);
const formatUsd = (micros: number) => {
const dollars = micros / 1_000_000;
if (dollars >= 100) return `$${dollars.toFixed(0)}`;
if (dollars >= 1) return `$${dollars.toFixed(2)}`;
// Sub-dollar balances need extra precision so the bar still tells the
// user what's left ("$0.04 of credit") instead of rounding to "$0".
if (dollars > 0) return `$${dollars.toFixed(3)}`;
return "$0";
};
return (
<div className="space-y-1.5">
<div className="flex justify-between items-center text-xs">
<span className="text-muted-foreground">
{formatUsd(me.premiumCreditMicrosUsed)} / {formatUsd(me.premiumCreditMicrosLimit)} of
credit
</span>
<span className="font-medium">{usagePercentage.toFixed(0)}%</span>
</div>
<Progress value={usagePercentage} className="h-1.5 [&>div]:bg-purple-500" />
</div>
);
}

View file

@ -1,6 +1,6 @@
"use client"; "use client";
import { CreditCard, Dot, SquarePen, Zap } from "lucide-react"; import { CreditCard, SquarePen, Zap } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { useParams } from "next/navigation"; import { useParams } from "next/navigation";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
@ -13,10 +13,9 @@ import { useIsAnonymous } from "@/contexts/anonymous-mode";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { SIDEBAR_MIN_WIDTH } from "../../hooks/useSidebarResize"; import { SIDEBAR_MIN_WIDTH } from "../../hooks/useSidebarResize";
import type { ChatItem, NavItem, PageUsage, SearchSpace, User } from "../../types/layout.types"; import type { ChatItem, NavItem, PageUsage, SearchSpace, User } from "../../types/layout.types";
import { AuthenticatedPageUsageDisplay } from "./AuthenticatedPageUsageDisplay";
import { ChatListItem } from "./ChatListItem"; import { ChatListItem } from "./ChatListItem";
import { CreditBalanceDisplay } from "./CreditBalanceDisplay";
import { NavSection } from "./NavSection"; import { NavSection } from "./NavSection";
import { PremiumTokenUsageDisplay } from "./PremiumTokenUsageDisplay";
import { SidebarButton } from "./SidebarButton"; import { SidebarButton } from "./SidebarButton";
import { SidebarCollapseButton } from "./SidebarCollapseButton"; import { SidebarCollapseButton } from "./SidebarCollapseButton";
import { SidebarHeader } from "./SidebarHeader"; import { SidebarHeader } from "./SidebarHeader";
@ -404,17 +403,16 @@ function SidebarUsageFooter({
return ( return (
<div className={containerClass}> <div className={containerClass}>
<PremiumTokenUsageDisplay /> <CreditBalanceDisplay />
<AuthenticatedPageUsageDisplay />
<div className="space-y-0.5"> <div className="space-y-0.5">
<Link <Link
href={`/dashboard/${searchSpaceId}/more-pages`} href={`/dashboard/${searchSpaceId}/earn-credits`}
onClick={onNavigate} onClick={onNavigate}
className="group flex w-full items-center justify-between rounded-md px-1.5 py-1 transition-colors hover:bg-accent" className="group flex w-full items-center justify-between rounded-md px-1.5 py-1 transition-colors hover:bg-accent"
> >
<span className="flex items-center gap-1.5 text-xs text-muted-foreground group-hover:text-accent-foreground"> <span className="flex items-center gap-1.5 text-xs text-muted-foreground group-hover:text-accent-foreground">
<Zap className="h-3 w-3 shrink-0" /> <Zap className="h-3 w-3 shrink-0" />
Get Free Pages Earn credits
</span> </span>
<Badge className="h-4 rounded px-1 text-[10px] font-semibold leading-none bg-emerald-600 text-white border-transparent hover:bg-emerald-600"> <Badge className="h-4 rounded px-1 text-[10px] font-semibold leading-none bg-emerald-600 text-white border-transparent hover:bg-emerald-600">
FREE FREE
@ -427,12 +425,7 @@ function SidebarUsageFooter({
> >
<span className="flex items-center gap-1.5 text-xs text-muted-foreground group-hover:text-accent-foreground"> <span className="flex items-center gap-1.5 text-xs text-muted-foreground group-hover:text-accent-foreground">
<CreditCard className="h-3 w-3 shrink-0" /> <CreditCard className="h-3 w-3 shrink-0" />
Buy More Buy credits
</span>
<span className="flex items-center text-[10px] font-medium text-muted-foreground">
$1/1k
<Dot className="h-3 w-3" />
$1/1M
</span> </span>
</Link> </Link>
</div> </div>

View file

@ -3,8 +3,8 @@ export { ChatListItem } from "./ChatListItem";
export { DocumentsSidebar } from "./DocumentsSidebar"; export { DocumentsSidebar } from "./DocumentsSidebar";
export { InboxSidebar, InboxSidebarContent } from "./InboxSidebar"; export { InboxSidebar, InboxSidebarContent } from "./InboxSidebar";
export { MobileSidebar, MobileSidebarTrigger } from "./MobileSidebar"; export { MobileSidebar, MobileSidebarTrigger } from "./MobileSidebar";
export { CreditBalanceDisplay } from "./CreditBalanceDisplay";
export { NavSection } from "./NavSection"; export { NavSection } from "./NavSection";
export { PageUsageDisplay } from "./PageUsageDisplay";
export { Sidebar } from "./Sidebar"; export { Sidebar } from "./Sidebar";
export { SidebarCollapseButton } from "./SidebarCollapseButton"; export { SidebarCollapseButton } from "./SidebarCollapseButton";
export { SidebarHeader } from "./SidebarHeader"; export { SidebarHeader } from "./SidebarHeader";

View file

@ -14,11 +14,11 @@ const demoPlans = [
price: "0", price: "0",
yearlyPrice: "0", yearlyPrice: "0",
period: "", period: "",
billingText: "500 pages + $5 in premium credits included", billingText: "$5 of credit included to start",
features: [ features: [
"Self Hostable", "Self Hostable",
"500 pages included to start", "$5 of credit included to start",
"$5 in premium credits for paid AI models and premium AI features", "One credit balance for document processing and premium AI features",
"Includes access to OpenAI text, audio and image models", "Includes access to OpenAI text, audio and image models",
"AI automations and agents: scheduled and event-triggered workflows", "AI automations and agents: scheduled and event-triggered workflows",
"Desktop app: Quick, General and Screenshot Assist plus local folder sync", "Desktop app: Quick, General and Screenshot Assist plus local folder sync",
@ -38,7 +38,7 @@ const demoPlans = [
billingText: "No subscription, buy only when you need more", billingText: "No subscription, buy only when you need more",
features: [ features: [
"Everything in Free", "Everything in Free",
"Buy 1,000-page packs or $1 in premium credits at $1 each", "Buy credit in $1 packs — $1 buys $1 of credit, with optional auto-reload",
"Use premium AI models like GPT-5.4, Claude Sonnet 4.6, Gemini 2.5 Pro & 100+ more via OpenRouter", "Use premium AI models like GPT-5.4, Claude Sonnet 4.6, Gemini 2.5 Pro & 100+ more via OpenRouter",
"Connector write-back to Notion, Slack, Linear & Jira", "Connector write-back to Notion, Slack, Linear & Jira",
"Priority support on Discord", "Priority support on Discord",
@ -84,32 +84,32 @@ interface FAQSection {
const faqData: FAQSection[] = [ const faqData: FAQSection[] = [
{ {
title: "Pages & Document Billing", title: "Credits & Document Billing",
items: [ items: [
{ {
question: 'What exactly is a "page" in SurfSense?', question: "What are credits in SurfSense?",
answer: answer:
"A page is a simple billing unit that measures how much content you add to your knowledge base. For PDFs, one page equals one real PDF page. For other document types like Word, PowerPoint, and Excel files, pages are automatically estimated based on the file. Every file uses at least 1 page.", "Credits are a single prepaid balance shown in dollars that powers everything in SurfSense — both document processing and premium AI features. New accounts start with $5 of credit. Your balance goes down as you use the product and back up when you top up or earn more, so there's just one number to keep an eye on.",
}, },
{ {
question: "What are Basic and Premium processing modes?", question: "How much does document processing cost?",
answer: answer:
"When uploading documents, you can choose between two processing modes. Basic mode uses standard extraction and costs 1 page credit per page, great for most documents. Premium processing mode uses advanced extraction optimized for complex financial, medical, and legal documents with intricate tables, layouts, and formatting. It costs 10 page credits per page and does not use your premium AI credits.", "Document processing is billed per page out of your credit balance. For PDFs, one page equals one real PDF page; for other document types like Word, PowerPoint, and Excel files, pages are automatically estimated. Basic mode costs $0.001 per page and Premium mode costs $0.01 per page. Premium processing uses advanced extraction optimized for complex financial, medical, and legal documents with intricate tables and layouts. Every file uses at least 1 page.",
}, },
{ {
question: "How does the Pay As You Go plan work?", question: "How does the Pay As You Go plan work?",
answer: answer:
"There's no monthly subscription. When you need more pages, simply purchase 1,000-page packs at $1 each. Purchased pages are added to your account immediately so you can keep indexing right away. You only pay when you actually need more.", "There's no monthly subscription. When you need more credit, simply buy $1 packs — $1 buys exactly $1 of credit. Purchased credit is added to your balance immediately so you can keep working right away. You only pay when you actually need more, and you can enable auto-reload to top up automatically.",
}, },
{ {
question: "What happens if I run out of pages?", question: "What happens if I run out of credit?",
answer: answer:
"SurfSense checks your remaining pages before processing each file. If you don't have enough, the upload is paused and you'll be notified. You can purchase additional page packs at any time to continue. For cloud connector syncs, a small overage may be allowed so your sync doesn't partially fail.", "SurfSense checks your remaining credit before processing each file. If you don't have enough, the upload is paused and you'll be notified so you can buy more credit and continue. For cloud connector syncs, a small overage may be allowed so your sync doesn't partially fail.",
}, },
{ {
question: "If I delete a document, do I get my pages back?", question: "If I delete a document, do I get my credit back?",
answer: answer:
"No. Deleting a document removes it from your knowledge base, but the pages it used are not refunded. Pages track your total usage over time, not how much is currently stored. So be mindful of what you index. Once pages are spent, they're spent even if you later remove the document.", "No. Deleting a document removes it from your knowledge base, but the credit it used is not refunded. Credit tracks your total usage over time, not how much is currently stored, so be mindful of what you index. Once credit is spent, it's spent even if you later remove the document.",
}, },
], ],
}, },
@ -117,49 +117,49 @@ const faqData: FAQSection[] = [
title: "File Types & Connectors", title: "File Types & Connectors",
items: [ items: [
{ {
question: "Which file types count toward my page limit?", question: "Which file types use credit?",
answer: answer:
"Page limits only apply to document files that need processing, including PDFs, Word documents (DOC, DOCX, ODT, RTF), presentations (PPT, PPTX, ODP), spreadsheets (XLS, XLSX, ODS), ebooks (EPUB), and images (JPG, PNG, TIFF, WebP, BMP). Plain text files, code files, Markdown, CSV, TSV, HTML, audio, and video files do not consume any pages.", "Credit is only used for document files that need processing, including PDFs, Word documents (DOC, DOCX, ODT, RTF), presentations (PPT, PPTX, ODP), spreadsheets (XLS, XLSX, ODS), ebooks (EPUB), and images (JPG, PNG, TIFF, WebP, BMP). Plain text files, code files, Markdown, CSV, TSV, HTML, audio, and video files do not consume any credit.",
}, },
{ {
question: "How are pages consumed?", question: "How is credit consumed for documents?",
answer: answer:
"Pages are deducted whenever a document file is successfully indexed into your knowledge base, whether through direct uploads or file-based connector syncs (Google Drive, OneDrive, Dropbox, Local Folder). In Basic mode, each page costs 1 page credit; in Premium mode, each page costs 10 page credits. SurfSense checks your remaining credits before processing and only charges you after the file is indexed. Duplicate documents are automatically detected and won't cost you extra pages.", "Credit is deducted whenever a document file is successfully indexed into your knowledge base, whether through direct uploads or file-based connector syncs (Google Drive, OneDrive, Dropbox, Local Folder). In Basic mode each page costs $0.001; in Premium mode each page costs $0.01. SurfSense checks your remaining credit before processing and only charges you after the file is indexed. Duplicate documents are automatically detected and won't cost you extra.",
}, },
{ {
question: "Do connectors like Slack, Notion, or Gmail use pages?", question: "Do connectors like Slack, Notion, or Gmail use credit?",
answer: answer:
"No. Connectors that work with structured text data like Slack, Discord, Notion, Confluence, Jira, Linear, ClickUp, GitHub, Gmail, Google Calendar, Microsoft Teams, Airtable, Elasticsearch, Web Crawler, BookStack, Obsidian, and Luma do not use pages at all. Page limits only apply to file-based connectors that need document processing, such as Google Drive, OneDrive, Dropbox, and Local Folder syncs.", "No. Connectors that work with structured text data like Slack, Discord, Notion, Confluence, Jira, Linear, ClickUp, GitHub, Gmail, Google Calendar, Microsoft Teams, Airtable, Elasticsearch, Web Crawler, BookStack, Obsidian, and Luma do not use credit at all. Document-processing charges only apply to file-based connectors such as Google Drive, OneDrive, Dropbox, and Local Folder syncs.",
}, },
], ],
}, },
{ {
title: "Premium Credits", title: "Premium AI & Credit",
items: [ items: [
{ {
question: 'What are "premium credits"?', question: "How is credit used for premium AI?",
answer: answer:
"Premium credits are your USD balance for paid AI usage in SurfSense, including premium AI models like GPT-5.4, Claude Sonnet 4.6, and Gemini 2.5 Pro, plus premium AI features such as image generation, podcasts, and video presentations when they use paid models. Each request debits the actual USD provider cost, so cheaper and more expensive models bill proportionally.", "The same credit balance pays for paid AI usage in SurfSense, including premium AI models like GPT-5.4, Claude Sonnet 4.6, and Gemini 2.5 Pro, plus premium AI features such as image generation, podcasts, and video presentations when they use paid models. Each request debits the actual USD provider cost, so cheaper and more expensive models bill proportionally.",
}, },
{ {
question: "How many premium credits do I get for free?", question: "How much credit do I get for free?",
answer: answer:
"Every registered SurfSense account starts with $5 in premium credits at no cost. Anonymous users (no login) get 500,000 free tokens across free models before creating an account. Once your included premium credits run out, you can top up at any time.", "Every registered SurfSense account starts with $5 of credit at no cost. Anonymous users (no login) get 500,000 free tokens across free models before creating an account. Once your included credit runs out, you can top up at any time or earn more by completing tasks.",
}, },
{ {
question: "How does buying premium credits work?", question: "How does buying credit work?",
answer: answer:
"Premium credit top-ups are pay as you go, with no subscription. $1 buys $1 of credit, and your balance is spent at provider cost. Purchased credit is added to your account immediately. You can buy up to $100 at a time.", "Top-ups are pay as you go, with no subscription. $1 buys $1 of credit, and your balance is spent at provider cost. Purchased credit is added to your account immediately, and you can buy any custom amount. Enable auto-reload to top up automatically when your balance runs low.",
}, },
{ {
question: "Are premium credits the same as page credits?", question: "Is there a separate balance for documents and AI?",
answer: answer:
"No. Page credits pay for document indexing and file-based connector processing. Premium credits pay for paid AI usage, such as premium model chats and premium AI generation features. Premium document processing mode sounds similar, but it consumes page credits, not premium credits.", "No. SurfSense uses one unified credit balance for everything — document indexing, file-based connector processing, premium model chats, and premium AI generation features all draw from the same wallet. Premium document processing mode simply costs more per page ($0.01 vs $0.001), but it's the same credit.",
}, },
{ {
question: "What happens if I run out of premium credits?", question: "What happens if I run out of credit?",
answer: answer:
"When your premium credit balance runs low, you'll see a warning. Once you run out, paid model requests and premium AI features are paused until you top up. You can still use non-premium models and features that do not consume premium credits.", "When your credit balance runs low, you'll see a warning. Once you run out, paid model requests, premium AI features, and document processing are paused until you top up. You can still use non-premium models and features that do not consume credit.",
}, },
], ],
}, },
@ -174,7 +174,7 @@ const faqData: FAQSection[] = [
{ {
question: "Do automations and agents cost extra?", question: "Do automations and agents cost extra?",
answer: answer:
"No. There is no separate subscription or add-on fee for automations. Agents use the same page credits and premium credits as the rest of SurfSense. Indexing documents consumes page credits, and premium AI model usage during a workflow consumes premium credits at provider cost. If a workflow only uses free models, it does not touch your premium credits.", "No. There is no separate subscription or add-on fee for automations. Agents draw from the same unified credit balance as the rest of SurfSense. Indexing documents and premium AI model usage during a workflow both consume credit at provider cost. If a workflow only uses free models and indexes no documents, it does not touch your credit.",
}, },
{ {
question: "How do event-triggered automations work?", question: "How do event-triggered automations work?",
@ -192,9 +192,9 @@ const faqData: FAQSection[] = [
title: "Self-Hosting", title: "Self-Hosting",
items: [ items: [
{ {
question: "Can I self-host SurfSense with unlimited pages and credit?", question: "Can I self-host SurfSense with unlimited usage?",
answer: answer:
"Yes! When self-hosting, you have full control over your page and premium credit limits. The default self-hosted setup gives you effectively unlimited pages and premium credits, so you can index as much data and use as many AI queries as your infrastructure supports.", "Yes! When self-hosting, you have full control over billing. The default self-hosted setup leaves document-processing credit billing off and gives you effectively unlimited credit, so you can index as much data and use as many AI queries as your infrastructure supports.",
}, },
], ],
}, },
@ -286,7 +286,7 @@ function PricingFAQ() {
Frequently Asked Questions Frequently Asked Questions
</h2> </h2>
<p className="mx-auto mt-4 max-w-2xl text-lg text-muted-foreground"> <p className="mx-auto mt-4 max-w-2xl text-lg text-muted-foreground">
Everything you need to know about SurfSense pages, premium credits, and billing. Everything you need to know about SurfSense credits and billing.
Can&apos;t find what you need? Reach out at{" "} Can&apos;t find what you need? Reach out at{" "}
<a href="mailto:rohan@surfsense.com" className="text-blue-500 underline"> <a href="mailto:rohan@surfsense.com" className="text-blue-500 underline">
rohan@surfsense.com rohan@surfsense.com
@ -372,7 +372,7 @@ function PricingBasic() {
<Pricing <Pricing
plans={demoPlans} plans={demoPlans}
title="SurfSense Pricing" title="SurfSense Pricing"
description="Start free with 500 pages & $5 in premium credits. Run AI automations and agents, and pay as you go." description="Start free with $5 of credit. Run AI automations and agents, and pay as you go."
/> />
<PricingFAQ /> <PricingFAQ />
</> </>

View file

@ -0,0 +1,276 @@
"use client";
import { useQuery as useZeroQuery } from "@rocicorp/zero/react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { AlertTriangle, CreditCard, RefreshCw } from "lucide-react";
import { useParams, usePathname, useRouter, useSearchParams } from "next/navigation";
import { useEffect, useRef, useState } from "react";
import { toast } from "sonner";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Spinner } from "@/components/ui/spinner";
import { Switch } from "@/components/ui/switch";
import { stripeApiService } from "@/lib/apis/stripe-api.service";
import { AppError } from "@/lib/error";
import { queries } from "@/zero/queries";
const microsToDollars = (micros: number | null | undefined): string => {
if (micros == null) return "";
return (micros / 1_000_000).toString();
};
const dollarsToMicros = (value: string): number | null => {
const trimmed = value.trim();
if (trimmed === "") return null;
const dollars = Number(trimmed);
if (!Number.isFinite(dollars) || dollars < 0) return null;
return Math.round(dollars * 1_000_000);
};
const formatUsd = (micros: number) => `$${(Math.max(0, micros) / 1_000_000).toFixed(2)}`;
export function AutoReloadSettings() {
const params = useParams();
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
const queryClient = useQueryClient();
const searchSpaceId = Number(params?.search_space_id);
const [enabled, setEnabled] = useState(false);
const [thresholdInput, setThresholdInput] = useState("");
const [amountInput, setAmountInput] = useState("");
const seededRef = useRef(false);
const [me] = useZeroQuery(queries.user.me({}));
const balanceMicros = me?.creditMicrosBalance ?? 0;
const { data: settings, isLoading } = useQuery({
queryKey: ["auto-reload-settings"],
queryFn: () => stripeApiService.getAutoReloadSettings(),
});
// Seed the form once from the server, then let the user own the inputs.
useEffect(() => {
if (settings && !seededRef.current) {
seededRef.current = true;
setEnabled(settings.enabled);
setThresholdInput(microsToDollars(settings.threshold_micros));
setAmountInput(microsToDollars(settings.amount_micros));
}
}, [settings]);
// Surface the result of the Stripe card-setup redirect.
useEffect(() => {
const setupResult = searchParams.get("auto_reload_setup");
if (!setupResult) return;
if (setupResult === "success") {
toast.success("Card saved. You can now enable auto-reload.");
queryClient.invalidateQueries({ queryKey: ["auto-reload-settings"] });
} else if (setupResult === "cancel") {
toast.info("Card setup canceled.");
}
// Strip the query param so refreshes don't re-toast.
router.replace(pathname);
}, [searchParams, router, pathname, queryClient]);
const setupMutation = useMutation({
mutationFn: () =>
stripeApiService.createAutoReloadSetupSession({ search_space_id: searchSpaceId }),
onSuccess: (response) => {
window.location.assign(response.checkout_url);
},
onError: () => {
toast.error("Couldn't start card setup. Please try again.");
},
});
const saveMutation = useMutation({
mutationFn: stripeApiService.updateAutoReloadSettings,
onSuccess: (updated) => {
queryClient.setQueryData(["auto-reload-settings"], updated);
toast.success(updated.enabled ? "Auto-reload is on." : "Auto-reload settings saved.");
},
onError: (error) => {
if (error instanceof AppError && error.message) {
toast.error(error.message);
return;
}
toast.error("Couldn't save auto-reload settings. Please try again.");
},
});
// Render nothing while loading (avoids a spinner flash on pages where the
// feature flag turns out to be off) and when auto-reload is disabled
// server-side.
if (isLoading || !settings || !settings.feature_enabled) {
return null;
}
const minAmountDollars = (settings.min_amount_micros / 1_000_000).toFixed(2);
const hasCard = settings.has_payment_method;
const handleSave = () => {
if (!enabled) {
saveMutation.mutate({
enabled: false,
threshold_micros: dollarsToMicros(thresholdInput),
amount_micros: dollarsToMicros(amountInput),
});
return;
}
const thresholdMicros = dollarsToMicros(thresholdInput);
const amountMicros = dollarsToMicros(amountInput);
if (!thresholdMicros || thresholdMicros <= 0) {
toast.error("Enter a low-balance threshold greater than $0.");
return;
}
if (amountMicros == null || amountMicros < settings.min_amount_micros) {
toast.error(`Reload amount must be at least $${minAmountDollars}.`);
return;
}
saveMutation.mutate({
enabled: true,
threshold_micros: thresholdMicros,
amount_micros: amountMicros,
});
};
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<RefreshCw className="h-4 w-4 text-amber-500" />
Auto-reload
</CardTitle>
<CardDescription>
Automatically top up your credit balance when it drops below a threshold, using a saved
card. Current balance:{" "}
<span className="font-medium text-foreground">{formatUsd(balanceMicros)}</span>.
</CardDescription>
</CardHeader>
<CardContent className="space-y-5">
{settings.failed_at && (
<Alert variant="destructive">
<AlertTriangle className="h-4 w-4" />
<AlertTitle>Last auto-reload failed</AlertTitle>
<AlertDescription>
Your saved card was declined and auto-reload was turned off. Update your card and
re-enable it below to keep topping up automatically.
</AlertDescription>
</Alert>
)}
{!hasCard ? (
<div className="flex flex-col items-start gap-3 rounded-lg border bg-muted/20 p-4">
<div className="flex items-center gap-2 text-sm">
<CreditCard className="h-4 w-4 text-muted-foreground" />
<span>Add a card to enable automatic top-ups.</span>
</div>
<Button onClick={() => setupMutation.mutate()} disabled={setupMutation.isPending}>
{setupMutation.isPending ? (
<>
<Spinner size="xs" />
Redirecting
</>
) : (
"Add a card"
)}
</Button>
</div>
) : (
<>
<div className="flex items-center justify-between gap-4">
<div className="space-y-0.5">
<Label htmlFor="auto-reload-toggle" className="text-sm font-medium">
Enable auto-reload
</Label>
<p className="text-xs text-muted-foreground">
Charge your saved card when the balance gets low.
</p>
</div>
<Switch id="auto-reload-toggle" checked={enabled} onCheckedChange={setEnabled} />
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-1.5">
<Label htmlFor="auto-reload-threshold" className="text-xs">
When balance falls below
</Label>
<div className="relative">
<span className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-sm text-muted-foreground">
$
</span>
<Input
id="auto-reload-threshold"
type="number"
min="0"
step="1"
inputMode="decimal"
className="pl-6 tabular-nums"
value={thresholdInput}
onChange={(e) => setThresholdInput(e.target.value)}
disabled={!enabled}
placeholder="5"
/>
</div>
</div>
<div className="space-y-1.5">
<Label htmlFor="auto-reload-amount" className="text-xs">
Add this much credit
</Label>
<div className="relative">
<span className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-sm text-muted-foreground">
$
</span>
<Input
id="auto-reload-amount"
type="number"
min={minAmountDollars}
step="1"
inputMode="decimal"
className="pl-6 tabular-nums"
value={amountInput}
onChange={(e) => setAmountInput(e.target.value)}
disabled={!enabled}
placeholder="10"
/>
</div>
<p className="text-[11px] text-muted-foreground">Minimum ${minAmountDollars}.</p>
</div>
</div>
<div className="flex items-center justify-between gap-3">
<Button
variant="ghost"
size="sm"
className="text-muted-foreground"
onClick={() => setupMutation.mutate()}
disabled={setupMutation.isPending}
>
<CreditCard className="h-3.5 w-3.5" />
Update card
</Button>
<Button onClick={handleSave} disabled={saveMutation.isPending}>
{saveMutation.isPending ? (
<>
<Spinner size="xs" />
Saving
</>
) : (
"Save"
)}
</Button>
</div>
</>
)}
</CardContent>
</Card>
);
}

View file

@ -7,46 +7,59 @@ import { useParams } from "next/navigation";
import { useState } from "react"; import { useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Progress } from "@/components/ui/progress";
import { Spinner } from "@/components/ui/spinner"; import { Spinner } from "@/components/ui/spinner";
import { stripeApiService } from "@/lib/apis/stripe-api.service"; import { stripeApiService } from "@/lib/apis/stripe-api.service";
import { AppError } from "@/lib/error"; import { AppError } from "@/lib/error";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { queries } from "@/zero/queries"; import { queries } from "@/zero/queries";
// One pack = $1.00 of credit, stored as 1_000_000 micro-USD on the // One pack = $1.00 of credit, stored as 1_000_000 micro-USD on the backend.
// backend. Premium turns are debited at the actual provider cost // ETL page processing and premium turns are both debited from the same wallet
// reported by LiteLLM, so $1 of credit always buys $1 of provider // at the actual cost, so $1 of credit always buys $1 of usage at cost.
// usage at cost.
const CREDIT_PER_PACK_MICROS = 1_000_000; const CREDIT_PER_PACK_MICROS = 1_000_000;
const PRICE_PER_PACK_USD = 1; const PRICE_PER_PACK_USD = 1;
const PRESET_MULTIPLIERS = [1, 2, 5, 10, 25, 50] as const; const PRESET_MULTIPLIERS = [1, 2, 5, 10, 25, 50, 100] as const;
const MIN_QUANTITY = 1;
const MAX_QUANTITY = 10_000;
const formatUsd = (micros: number, options?: { compact?: boolean }) => { const clampQuantity = (value: number) =>
const dollars = micros / 1_000_000; Math.min(MAX_QUANTITY, Math.max(MIN_QUANTITY, Math.floor(value)));
if (options?.compact && dollars >= 1) return `$${dollars.toFixed(2)}`;
const formatUsd = (micros: number) => {
// Clamp at $0.00 — the balance can dip slightly negative when actual cost
// exceeds the pre-charge estimate.
const dollars = Math.max(0, micros) / 1_000_000;
if (dollars >= 100) return `$${dollars.toFixed(0)}`; if (dollars >= 100) return `$${dollars.toFixed(0)}`;
if (dollars >= 1) return `$${dollars.toFixed(2)}`; if (dollars >= 1) return `$${dollars.toFixed(2)}`;
if (dollars > 0) return `$${dollars.toFixed(3)}`; if (dollars > 0) return `$${dollars.toFixed(3)}`;
return "$0"; return "$0.00";
}; };
export function BuyTokensContent() { export function BuyCreditsContent() {
const params = useParams(); const params = useParams();
const searchSpaceId = Number(params?.search_space_id); const searchSpaceId = Number(params?.search_space_id);
const [quantity, setQuantity] = useState(1); const [quantity, setQuantity] = useState(1);
// Raw text of the amount field so the user can clear it while typing;
// committed back to a clamped integer on blur.
const [amountInput, setAmountInput] = useState("1");
const commitQuantity = (value: number) => {
const clamped = clampQuantity(Number.isFinite(value) ? value : MIN_QUANTITY);
setQuantity(clamped);
setAmountInput(String(clamped));
};
// Server config flag: stays on REST, not per-user. // Server config flag: stays on REST, not per-user.
const { data: tokenStatus } = useQuery({ const { data: creditStatus } = useQuery({
queryKey: ["token-status"], queryKey: ["credit-status"],
queryFn: () => stripeApiService.getTokenStatus(), queryFn: () => stripeApiService.getCreditStatus(),
}); });
// Live per-user balance via Zero. // Live per-user balance via Zero.
const [me] = useZeroQuery(queries.user.me({})); const [me] = useZeroQuery(queries.user.me({}));
const purchaseMutation = useMutation({ const purchaseMutation = useMutation({
mutationFn: stripeApiService.createTokenCheckoutSession, mutationFn: stripeApiService.createCreditCheckoutSession,
onSuccess: (response) => { onSuccess: (response) => {
window.location.assign(response.checkout_url); window.location.assign(response.checkout_url);
}, },
@ -62,10 +75,10 @@ export function BuyTokensContent() {
const totalCreditMicros = quantity * CREDIT_PER_PACK_MICROS; const totalCreditMicros = quantity * CREDIT_PER_PACK_MICROS;
const totalPrice = quantity * PRICE_PER_PACK_USD; const totalPrice = quantity * PRICE_PER_PACK_USD;
if (tokenStatus && !tokenStatus.token_buying_enabled) { if (creditStatus && !creditStatus.credit_buying_enabled) {
return ( return (
<div className="w-full space-y-3 text-center"> <div className="w-full space-y-3 text-center">
<h2 className="text-xl font-bold tracking-tight">Buy Premium Credit</h2> <h2 className="text-xl font-bold tracking-tight">Buy Credits</h2>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
Credit purchases are temporarily unavailable. Credit purchases are temporarily unavailable.
</p> </p>
@ -73,35 +86,20 @@ export function BuyTokensContent() {
); );
} }
const used = me?.premiumCreditMicrosUsed ?? 0; const balanceMicros = me?.creditMicrosBalance ?? creditStatus?.credit_micros_balance ?? 0;
const limit = me?.premiumCreditMicrosLimit ?? 0;
// Mirrors the backend formula in stripe_routes.py (max(0, limit - used)).
const remaining = Math.max(0, limit - used);
const usagePercentage = me ? Math.min((used / Math.max(limit, 1)) * 100, 100) : 0;
return ( return (
<div className="w-full space-y-5"> <div className="w-full space-y-5">
<div className="text-center"> <div className="text-center">
<h2 className="text-xl font-bold tracking-tight">Buy Premium Credit</h2> <h2 className="text-xl font-bold tracking-tight">Buy Credits</h2>
<p className="mt-1 text-sm text-muted-foreground">
$1 buys $1 of credit, billed at provider cost
</p>
</div> </div>
{me && ( <div className="rounded-lg border bg-muted/20 p-3">
<div className="rounded-lg border bg-muted/20 p-3 space-y-1.5"> <div className="flex items-center justify-between text-sm">
<div className="flex justify-between items-center text-xs"> <span className="text-muted-foreground">Current balance</span>
<span className="text-muted-foreground"> <span className="font-semibold tabular-nums">{formatUsd(balanceMicros)}</span>
{formatUsd(used)} / {formatUsd(limit)} of credit
</span>
<span className="font-medium">{usagePercentage.toFixed(0)}%</span>
</div>
<Progress value={usagePercentage} className="h-1.5 [&>div]:bg-purple-500" />
<p className="text-[11px] text-muted-foreground">
{formatUsd(remaining)} of credit remaining
</p>
</div> </div>
)} </div>
<div className="space-y-3"> <div className="space-y-3">
<div className="flex items-center justify-center gap-3"> <div className="flex items-center justify-center gap-3">
@ -109,21 +107,39 @@ export function BuyTokensContent() {
type="button" type="button"
variant="ghost" variant="ghost"
size="icon" size="icon"
onClick={() => setQuantity((q) => Math.max(1, q - 1))} onClick={() => commitQuantity(quantity - 1)}
disabled={quantity <= 1 || purchaseMutation.isPending} disabled={quantity <= MIN_QUANTITY || purchaseMutation.isPending}
className="size-8 text-muted-foreground shadow-none transition-colors hover:bg-muted hover:text-white disabled:opacity-40" className="size-8 text-muted-foreground shadow-none transition-colors hover:bg-muted hover:text-white disabled:opacity-40"
> >
<Minus className="h-3.5 w-3.5" /> <Minus className="h-3.5 w-3.5" />
</Button> </Button>
<span className="min-w-32 text-center text-lg font-semibold tabular-nums"> <div className="flex items-baseline gap-1.5">
${(totalCreditMicros / 1_000_000).toFixed(0)} of credit <span className="text-lg font-semibold">$</span>
</span> <input
type="text"
inputMode="numeric"
value={amountInput}
onChange={(e) => {
const raw = e.target.value.replace(/[^0-9]/g, "");
setAmountInput(raw);
const parsed = Number.parseInt(raw, 10);
if (Number.isFinite(parsed)) {
setQuantity(clampQuantity(parsed));
}
}}
onBlur={() => commitQuantity(Number.parseInt(amountInput, 10))}
disabled={purchaseMutation.isPending}
aria-label="Credit amount in US dollars"
className="w-20 rounded-md border bg-transparent px-2 py-1 text-center text-lg font-semibold tabular-nums outline-none focus:ring-2 focus:ring-ring disabled:opacity-60"
/>
<span className="text-sm text-muted-foreground">of credit</span>
</div>
<Button <Button
type="button" type="button"
variant="ghost" variant="ghost"
size="icon" size="icon"
onClick={() => setQuantity((q) => Math.min(100, q + 1))} onClick={() => commitQuantity(quantity + 1)}
disabled={quantity >= 100 || purchaseMutation.isPending} disabled={quantity >= MAX_QUANTITY || purchaseMutation.isPending}
className="size-8 text-muted-foreground shadow-none transition-colors hover:bg-muted hover:text-white disabled:opacity-40" className="size-8 text-muted-foreground shadow-none transition-colors hover:bg-muted hover:text-white disabled:opacity-40"
> >
<Plus className="h-3.5 w-3.5" /> <Plus className="h-3.5 w-3.5" />
@ -136,7 +152,7 @@ export function BuyTokensContent() {
key={m} key={m}
type="button" type="button"
variant="ghost" variant="ghost"
onClick={() => setQuantity(m)} onClick={() => commitQuantity(m)}
disabled={purchaseMutation.isPending} disabled={purchaseMutation.isPending}
className={cn( className={cn(
"h-auto rounded-md px-2.5 py-1 text-xs font-medium tabular-nums transition-colors disabled:opacity-60", "h-auto rounded-md px-2.5 py-1 text-xs font-medium tabular-nums transition-colors disabled:opacity-60",

View file

@ -1,148 +0,0 @@
"use client";
import { useMutation, useQuery } from "@tanstack/react-query";
import { Minus, Plus } from "lucide-react";
import { useParams } from "next/navigation";
import { useState } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Spinner } from "@/components/ui/spinner";
import { stripeApiService } from "@/lib/apis/stripe-api.service";
import { AppError } from "@/lib/error";
import { cn } from "@/lib/utils";
const PAGE_PACK_SIZE = 1000;
const PRICE_PER_PACK_USD = 1;
const PRESET_MULTIPLIERS = [1, 2, 5, 10, 25, 50] as const;
export function BuyPagesContent() {
const params = useParams();
const [quantity, setQuantity] = useState(1);
const { data: stripeStatus } = useQuery({
queryKey: ["stripe-status"],
queryFn: () => stripeApiService.getStatus(),
});
const purchaseMutation = useMutation({
mutationFn: stripeApiService.createCheckoutSession,
onSuccess: (response) => {
window.location.assign(response.checkout_url);
},
onError: (error) => {
if (error instanceof AppError && error.message) {
toast.error(error.message);
return;
}
toast.error("Failed to start checkout. Please try again.");
},
});
const searchSpaceId = Number(params.search_space_id);
const hasValidSearchSpace = Number.isFinite(searchSpaceId) && searchSpaceId > 0;
const totalPages = quantity * PAGE_PACK_SIZE;
const totalPrice = quantity * PRICE_PER_PACK_USD;
if (stripeStatus && !stripeStatus.page_buying_enabled) {
return (
<div className="w-full space-y-3 text-center">
<h2 className="text-xl font-bold tracking-tight">Buy Pages</h2>
<p className="text-sm text-muted-foreground">Page purchases are temporarily unavailable.</p>
</div>
);
}
const handleBuyNow = () => {
if (!hasValidSearchSpace) {
toast.error("Unable to determine the current workspace for checkout.");
return;
}
purchaseMutation.mutate({
quantity,
search_space_id: searchSpaceId,
});
};
return (
<div className="w-full space-y-5">
<div className="text-center">
<h2 className="text-xl font-bold tracking-tight">Buy Pages</h2>
<p className="mt-1 text-sm text-muted-foreground">$1 per 1,000 pages, pay as you go</p>
</div>
<div className="space-y-3">
{/* Stepper */}
<div className="flex items-center justify-center gap-3">
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => setQuantity((q) => Math.max(1, q - 1))}
disabled={quantity <= 1 || purchaseMutation.isPending}
className="size-8 text-muted-foreground shadow-none transition-colors hover:bg-muted hover:text-white disabled:opacity-40"
>
<Minus className="h-3.5 w-3.5" />
</Button>
<span className="min-w-28 text-center text-lg font-semibold tabular-nums">
{totalPages.toLocaleString()}
</span>
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => setQuantity((q) => Math.min(100, q + 1))}
disabled={quantity >= 100 || purchaseMutation.isPending}
className="size-8 text-muted-foreground shadow-none transition-colors hover:bg-muted hover:text-white disabled:opacity-40"
>
<Plus className="h-3.5 w-3.5" />
</Button>
</div>
{/* Quick-pick presets */}
<div className="flex flex-wrap justify-center gap-1.5">
{PRESET_MULTIPLIERS.map((m) => (
<Button
key={m}
type="button"
variant="ghost"
onClick={() => setQuantity(m)}
disabled={purchaseMutation.isPending}
className={cn(
"h-auto rounded-md px-2.5 py-1 text-xs font-medium tabular-nums transition-colors disabled:opacity-60",
quantity === m
? "bg-accent text-accent-foreground"
: "text-muted-foreground hover:bg-accent hover:text-accent-foreground"
)}
>
{(m * PAGE_PACK_SIZE).toLocaleString()}
</Button>
))}
</div>
<div className="flex items-center justify-between rounded-lg border bg-muted/30 px-3 py-2">
<span className="text-sm font-medium tabular-nums">
{totalPages.toLocaleString()} pages
</span>
<span className="text-sm font-semibold tabular-nums">${totalPrice}</span>
</div>
<Button
className="w-full"
disabled={purchaseMutation.isPending || !hasValidSearchSpace}
onClick={handleBuyNow}
>
{purchaseMutation.isPending ? (
<>
<Spinner size="xs" />
Redirecting
</>
) : (
<>
Buy {totalPages.toLocaleString()} Pages for ${totalPrice}
</>
)}
</Button>
<p className="text-center text-[11px] text-muted-foreground">Secure checkout via Stripe</p>
</div>
</div>
);
}

View file

@ -22,7 +22,14 @@ import {
} from "@/lib/posthog/events"; } from "@/lib/posthog/events";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
export function MorePagesContent() { // Compact dollar label for a task's reward (e.g. "+$0.03").
const formatRewardUsd = (micros: number) => {
const dollars = micros / 1_000_000;
if (dollars >= 1) return `+$${dollars.toFixed(2)}`;
return `+$${dollars.toFixed(2)}`;
};
export function EarnCreditsContent() {
const params = useParams(); const params = useParams();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const searchSpaceId = params?.search_space_id ?? ""; const searchSpaceId = params?.search_space_id ?? "";
@ -35,11 +42,11 @@ export function MorePagesContent() {
queryKey: ["incentive-tasks"], queryKey: ["incentive-tasks"],
queryFn: () => incentiveTasksApiService.getTasks(), queryFn: () => incentiveTasksApiService.getTasks(),
}); });
const { data: stripeStatus } = useQuery({ const { data: creditStatus } = useQuery({
queryKey: ["stripe-status"], queryKey: ["credit-status"],
queryFn: () => stripeApiService.getStatus(), queryFn: () => stripeApiService.getCreditStatus(),
}); });
const pageBuyingEnabled = stripeStatus?.page_buying_enabled ?? true; const creditBuyingEnabled = creditStatus?.credit_buying_enabled ?? true;
const completeMutation = useMutation({ const completeMutation = useMutation({
mutationFn: incentiveTasksApiService.completeTask, mutationFn: incentiveTasksApiService.completeTask,
@ -48,7 +55,7 @@ export function MorePagesContent() {
toast.success(response.message); toast.success(response.message);
const task = data?.tasks.find((t) => t.task_type === taskType); const task = data?.tasks.find((t) => t.task_type === taskType);
if (task) { if (task) {
trackIncentiveTaskCompleted(taskType, task.pages_reward); trackIncentiveTaskCompleted(taskType, task.credit_micros_reward);
} }
queryClient.invalidateQueries({ queryKey: ["incentive-tasks"] }); queryClient.invalidateQueries({ queryKey: ["incentive-tasks"] });
queryClient.invalidateQueries({ queryKey: USER_QUERY_KEY }); queryClient.invalidateQueries({ queryKey: USER_QUERY_KEY });
@ -69,12 +76,14 @@ export function MorePagesContent() {
return ( return (
<div className="w-full space-y-5"> <div className="w-full space-y-5">
<div className="text-center"> <div className="text-center">
<h2 className="text-xl font-bold tracking-tight">Get Free Pages</h2> <h2 className="text-xl font-bold tracking-tight">Earn Credits</h2>
<p className="mt-1 text-sm text-muted-foreground">Earn bonus pages by completing tasks</p> <p className="mt-1 text-sm text-muted-foreground">
Earn bonus credits by completing tasks
</p>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<h3 className="text-sm font-semibold">Earn Bonus Pages</h3> <h3 className="text-sm font-semibold">Earn Bonus Credits</h3>
{isLoading ? ( {isLoading ? (
<div className="space-y-1.5"> <div className="space-y-1.5">
{["github", "reddit", "discord"].map((task) => ( {["github", "reddit", "discord"].map((task) => (
@ -97,14 +106,16 @@ export function MorePagesContent() {
<CardContent className="flex items-center gap-3 p-3"> <CardContent className="flex items-center gap-3 p-3">
<div <div
className={cn( className={cn(
"flex h-8 w-8 shrink-0 items-center justify-center rounded-full", "flex h-9 min-w-9 shrink-0 items-center justify-center rounded-full px-2",
task.completed ? "bg-primary text-primary-foreground" : "bg-muted" task.completed ? "bg-primary text-primary-foreground" : "bg-muted"
)} )}
> >
{task.completed ? ( {task.completed ? (
<Check className="h-3.5 w-3.5" /> <Check className="h-3.5 w-3.5" />
) : ( ) : (
<span className="text-xs font-semibold">+{task.pages_reward}</span> <span className="text-[11px] font-semibold tabular-nums">
{formatRewardUsd(task.credit_micros_reward)}
</span>
)} )}
</div> </div>
<p <p
@ -151,15 +162,13 @@ export function MorePagesContent() {
<div className="text-center"> <div className="text-center">
<p className="text-sm text-muted-foreground">Need more?</p> <p className="text-sm text-muted-foreground">Need more?</p>
{pageBuyingEnabled ? ( {creditBuyingEnabled ? (
<Button asChild variant="link" className="text-emerald-600 dark:text-emerald-400"> <Button asChild variant="link" className="text-emerald-600 dark:text-emerald-400">
<Link href={`/dashboard/${searchSpaceId}/buy-pages`}> <Link href={`/dashboard/${searchSpaceId}/buy-more`}>Buy credits at $1 per $1</Link>
Buy page packs at $1 per 1,000
</Link>
</Button> </Button>
) : ( ) : (
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
Page purchases are temporarily unavailable. Credit purchases are temporarily unavailable.
</p> </p>
)} )}
</div> </div>

View file

@ -20,8 +20,7 @@ export const registerRequest = loginRequest.omit({ grant_type: true, username: t
export const registerResponse = registerRequest.omit({ password: true }).extend({ export const registerResponse = registerRequest.omit({ password: true }).extend({
id: z.string(), id: z.string(),
pages_limit: z.number(), credit_micros_balance: z.number(),
pages_used: z.number(),
}); });
export type LoginRequest = z.infer<typeof loginRequest>; export type LoginRequest = z.infer<typeof loginRequest>;

View file

@ -11,7 +11,7 @@ export const inboxItemTypeEnum = z.enum([
"document_processing", "document_processing",
"new_mention", "new_mention",
"comment_reply", "comment_reply",
"page_limit_exceeded", "insufficient_credits",
]); ]);
/** /**
@ -116,15 +116,17 @@ export const commentReplyMetadata = z.object({
}); });
/** /**
* Page limit exceeded metadata schema * Insufficient credits metadata schema.
*
* ``balance_micros`` / ``required_micros`` are integer micro-USD
* (1_000_000 == $1.00); the UI divides by 1M when displaying.
*/ */
export const pageLimitExceededMetadata = baseInboxItemMetadata.extend({ export const insufficientCreditsMetadata = baseInboxItemMetadata.extend({
document_name: z.string(), document_name: z.string(),
document_type: z.string(), document_type: z.string(),
pages_used: z.number(), balance_micros: z.number(),
pages_limit: z.number(), required_micros: z.number(),
pages_to_add: z.number(), error_type: z.literal("insufficient_credits"),
error_type: z.literal("page_limit_exceeded"),
// Navigation target for frontend // Navigation target for frontend
action_url: z.string(), action_url: z.string(),
action_label: z.string(), action_label: z.string(),
@ -140,7 +142,7 @@ export const inboxItemMetadata = z.union([
documentProcessingMetadata, documentProcessingMetadata,
newMentionMetadata, newMentionMetadata,
commentReplyMetadata, commentReplyMetadata,
pageLimitExceededMetadata, insufficientCreditsMetadata,
baseInboxItemMetadata, baseInboxItemMetadata,
]); ]);
@ -188,9 +190,9 @@ export const commentReplyInboxItem = inboxItem.extend({
metadata: commentReplyMetadata, metadata: commentReplyMetadata,
}); });
export const pageLimitExceededInboxItem = inboxItem.extend({ export const insufficientCreditsInboxItem = inboxItem.extend({
type: z.literal("page_limit_exceeded"), type: z.literal("insufficient_credits"),
metadata: pageLimitExceededMetadata, metadata: insufficientCreditsMetadata,
}); });
// ============================================================================= // =============================================================================
@ -341,12 +343,12 @@ export function isCommentReplyMetadata(metadata: unknown): metadata is CommentRe
} }
/** /**
* Type guard for PageLimitExceededMetadata * Type guard for InsufficientCreditsMetadata
*/ */
export function isPageLimitExceededMetadata( export function isInsufficientCreditsMetadata(
metadata: unknown metadata: unknown
): metadata is PageLimitExceededMetadata { ): metadata is InsufficientCreditsMetadata {
return pageLimitExceededMetadata.safeParse(metadata).success; return insufficientCreditsMetadata.safeParse(metadata).success;
} }
/** /**
@ -361,7 +363,7 @@ export function parseInboxItemMetadata(
| DocumentProcessingMetadata | DocumentProcessingMetadata
| NewMentionMetadata | NewMentionMetadata
| CommentReplyMetadata | CommentReplyMetadata
| PageLimitExceededMetadata | InsufficientCreditsMetadata
| null { | null {
switch (type) { switch (type) {
case "connector_indexing": { case "connector_indexing": {
@ -384,8 +386,8 @@ export function parseInboxItemMetadata(
const result = commentReplyMetadata.safeParse(metadata); const result = commentReplyMetadata.safeParse(metadata);
return result.success ? result.data : null; return result.success ? result.data : null;
} }
case "page_limit_exceeded": { case "insufficient_credits": {
const result = pageLimitExceededMetadata.safeParse(metadata); const result = insufficientCreditsMetadata.safeParse(metadata);
return result.success ? result.data : null; return result.success ? result.data : null;
} }
default: default:
@ -406,7 +408,7 @@ export type ConnectorDeletionMetadata = z.infer<typeof connectorDeletionMetadata
export type DocumentProcessingMetadata = z.infer<typeof documentProcessingMetadata>; export type DocumentProcessingMetadata = z.infer<typeof documentProcessingMetadata>;
export type NewMentionMetadata = z.infer<typeof newMentionMetadata>; export type NewMentionMetadata = z.infer<typeof newMentionMetadata>;
export type CommentReplyMetadata = z.infer<typeof commentReplyMetadata>; export type CommentReplyMetadata = z.infer<typeof commentReplyMetadata>;
export type PageLimitExceededMetadata = z.infer<typeof pageLimitExceededMetadata>; export type InsufficientCreditsMetadata = z.infer<typeof insufficientCreditsMetadata>;
export type InboxItemMetadata = z.infer<typeof inboxItemMetadata>; export type InboxItemMetadata = z.infer<typeof inboxItemMetadata>;
export type InboxItem = z.infer<typeof inboxItem>; export type InboxItem = z.infer<typeof inboxItem>;
export type ConnectorIndexingInboxItem = z.infer<typeof connectorIndexingInboxItem>; export type ConnectorIndexingInboxItem = z.infer<typeof connectorIndexingInboxItem>;
@ -414,7 +416,7 @@ export type ConnectorDeletionInboxItem = z.infer<typeof connectorDeletionInboxIt
export type DocumentProcessingInboxItem = z.infer<typeof documentProcessingInboxItem>; export type DocumentProcessingInboxItem = z.infer<typeof documentProcessingInboxItem>;
export type NewMentionInboxItem = z.infer<typeof newMentionInboxItem>; export type NewMentionInboxItem = z.infer<typeof newMentionInboxItem>;
export type CommentReplyInboxItem = z.infer<typeof commentReplyInboxItem>; export type CommentReplyInboxItem = z.infer<typeof commentReplyInboxItem>;
export type PageLimitExceededInboxItem = z.infer<typeof pageLimitExceededInboxItem>; export type InsufficientCreditsInboxItem = z.infer<typeof insufficientCreditsInboxItem>;
// API Request/Response types // API Request/Response types
export type GetNotificationsRequest = z.infer<typeof getNotificationsRequest>; export type GetNotificationsRequest = z.infer<typeof getNotificationsRequest>;

View file

@ -12,7 +12,8 @@ export const incentiveTaskInfo = z.object({
task_type: incentiveTaskTypeEnum, task_type: incentiveTaskTypeEnum,
title: z.string(), title: z.string(),
description: z.string(), description: z.string(),
pages_reward: z.number(), // Reward in micro-USD (1_000_000 == $1.00) credited to the wallet.
credit_micros_reward: z.number(),
action_url: z.string(), action_url: z.string(),
completed: z.boolean(), completed: z.boolean(),
completed_at: z.string().nullable(), completed_at: z.string().nullable(),
@ -23,7 +24,7 @@ export const incentiveTaskInfo = z.object({
*/ */
export const getIncentiveTasksResponse = z.object({ export const getIncentiveTasksResponse = z.object({
tasks: z.array(incentiveTaskInfo), tasks: z.array(incentiveTaskInfo),
total_pages_earned: z.number(), total_credit_micros_earned: z.number(),
}); });
/** /**
@ -32,8 +33,8 @@ export const getIncentiveTasksResponse = z.object({
export const completeTaskSuccessResponse = z.object({ export const completeTaskSuccessResponse = z.object({
success: z.literal(true), success: z.literal(true),
message: z.string(), message: z.string(),
pages_awarded: z.number(), credit_micros_awarded: z.number(),
new_pages_limit: z.number(), new_balance_micros: z.number(),
}); });
/** /**

View file

@ -1,20 +1,49 @@
import { z } from "zod"; import { z } from "zod";
export const pagePurchaseStatusEnum = z.enum(["pending", "completed", "failed"]); export const purchaseStatusEnum = z.enum(["pending", "completed", "failed"]);
export const createCheckoutSessionRequest = z.object({ // ---------------------------------------------------------------------------
quantity: z.number().int().min(1).max(100), // Credit purchases ($1 packs that top up credit_micros_balance)
// ---------------------------------------------------------------------------
export const createCreditCheckoutSessionRequest = z.object({
quantity: z.number().int().min(1).max(10_000),
search_space_id: z.number().int().min(1), search_space_id: z.number().int().min(1),
}); });
export const createCheckoutSessionResponse = z.object({ export const createCreditCheckoutSessionResponse = z.object({
checkout_url: z.string(), checkout_url: z.string(),
}); });
export const stripeStatusResponse = z.object({ // Credit balance availability + records. Unit is integer micro-USD
page_buying_enabled: z.boolean(), // (1_000_000 == $1.00); the FE divides by 1M when displaying.
export const creditStripeStatusResponse = z.object({
credit_buying_enabled: z.boolean(),
credit_micros_balance: z.number().default(0),
}); });
export const creditPurchase = z.object({
id: z.uuid(),
stripe_checkout_session_id: z.string(),
stripe_payment_intent_id: z.string().nullable(),
quantity: z.number(),
credit_micros_granted: z.number(),
amount_total: z.number().nullable(),
currency: z.string().nullable(),
source: z.string().default("checkout"),
status: purchaseStatusEnum,
completed_at: z.string().nullable(),
created_at: z.string(),
});
export const getCreditPurchasesResponse = z.object({
purchases: z.array(creditPurchase),
});
// ---------------------------------------------------------------------------
// Legacy page purchases (read-only history; page buying is removed)
// ---------------------------------------------------------------------------
export const pagePurchase = z.object({ export const pagePurchase = z.object({
id: z.uuid(), id: z.uuid(),
stripe_checkout_session_id: z.string(), stripe_checkout_session_id: z.string(),
@ -23,7 +52,7 @@ export const pagePurchase = z.object({
pages_granted: z.number(), pages_granted: z.number(),
amount_total: z.number().nullable(), amount_total: z.number().nullable(),
currency: z.string().nullable(), currency: z.string().nullable(),
status: pagePurchaseStatusEnum, status: purchaseStatusEnum,
completed_at: z.string().nullable(), completed_at: z.string().nullable(),
created_at: z.string(), created_at: z.string(),
}); });
@ -32,70 +61,59 @@ export const getPagePurchasesResponse = z.object({
purchases: z.array(pagePurchase), purchases: z.array(pagePurchase),
}); });
// Premium credit purchases // Response from /stripe/finalize-checkout (credit purchases only).
export const createTokenCheckoutSessionRequest = z.object({ export const finalizeCheckoutResponse = z.object({
quantity: z.number().int().min(1).max(100), status: purchaseStatusEnum,
credit_micros_balance: z.number().default(0),
credit_micros_granted: z.number().nullable().optional(),
});
// ---------------------------------------------------------------------------
// Auto-reload (off-session top-up when the balance drops below a threshold)
// All *_micros fields are integer micro-USD (1_000_000 == $1.00).
// ---------------------------------------------------------------------------
export const autoReloadSettingsResponse = z.object({
feature_enabled: z.boolean(),
enabled: z.boolean().default(false),
threshold_micros: z.number().nullable(),
amount_micros: z.number().nullable(),
min_amount_micros: z.number(),
has_payment_method: z.boolean().default(false),
failed_at: z.string().nullable(),
});
export const updateAutoReloadSettingsRequest = z.object({
enabled: z.boolean(),
threshold_micros: z.number().int().min(0).nullable().optional(),
amount_micros: z.number().int().min(0).nullable().optional(),
});
export const createAutoReloadSetupSessionRequest = z.object({
search_space_id: z.number().int().min(1), search_space_id: z.number().int().min(1),
}); });
export const createTokenCheckoutSessionResponse = z.object({ export const createAutoReloadSetupSessionResponse = z.object({
checkout_url: z.string(), checkout_url: z.string(),
}); });
// Premium credit balance + purchase records. export type AutoReloadSettingsResponse = z.infer<typeof autoReloadSettingsResponse>;
// export type UpdateAutoReloadSettingsRequest = z.infer<typeof updateAutoReloadSettingsRequest>;
// The unit is integer micro-USD (1_000_000 == $1.00). The schema names export type CreateAutoReloadSetupSessionRequest = z.infer<
// kept the ``Token`` prefix for API back-compat with pinned clients; typeof createAutoReloadSetupSessionRequest
// the field names below are authoritative. >;
export const tokenStripeStatusResponse = z.object({ export type CreateAutoReloadSetupSessionResponse = z.infer<
token_buying_enabled: z.boolean(), typeof createAutoReloadSetupSessionResponse
premium_credit_micros_used: z.number().default(0), >;
premium_credit_micros_limit: z.number().default(0),
premium_credit_micros_remaining: z.number().default(0),
});
export const tokenPurchaseStatusEnum = pagePurchaseStatusEnum; export type PurchaseStatus = z.infer<typeof purchaseStatusEnum>;
export type CreateCreditCheckoutSessionRequest = z.infer<typeof createCreditCheckoutSessionRequest>;
export const tokenPurchase = z.object({ export type CreateCreditCheckoutSessionResponse = z.infer<
id: z.uuid(), typeof createCreditCheckoutSessionResponse
stripe_checkout_session_id: z.string(), >;
stripe_payment_intent_id: z.string().nullable(), export type CreditStripeStatusResponse = z.infer<typeof creditStripeStatusResponse>;
quantity: z.number(), export type CreditPurchase = z.infer<typeof creditPurchase>;
credit_micros_granted: z.number(), export type GetCreditPurchasesResponse = z.infer<typeof getCreditPurchasesResponse>;
amount_total: z.number().nullable(),
currency: z.string().nullable(),
status: tokenPurchaseStatusEnum,
completed_at: z.string().nullable(),
created_at: z.string(),
});
export const getTokenPurchasesResponse = z.object({
purchases: z.array(tokenPurchase),
});
// Response from /stripe/finalize-checkout. Either page or token fields
// are populated depending on purchase_type.
export const finalizeCheckoutResponse = z.object({
purchase_type: z.enum(["page_packs", "premium_tokens"]),
status: pagePurchaseStatusEnum,
pages_limit: z.number().nullable().optional(),
pages_used: z.number().nullable().optional(),
pages_granted: z.number().nullable().optional(),
premium_credit_micros_limit: z.number().nullable().optional(),
premium_credit_micros_used: z.number().nullable().optional(),
premium_credit_micros_granted: z.number().nullable().optional(),
});
export type PagePurchaseStatus = z.infer<typeof pagePurchaseStatusEnum>;
export type CreateCheckoutSessionRequest = z.infer<typeof createCheckoutSessionRequest>;
export type CreateCheckoutSessionResponse = z.infer<typeof createCheckoutSessionResponse>;
export type StripeStatusResponse = z.infer<typeof stripeStatusResponse>;
export type PagePurchase = z.infer<typeof pagePurchase>; export type PagePurchase = z.infer<typeof pagePurchase>;
export type GetPagePurchasesResponse = z.infer<typeof getPagePurchasesResponse>; export type GetPagePurchasesResponse = z.infer<typeof getPagePurchasesResponse>;
export type CreateTokenCheckoutSessionRequest = z.infer<typeof createTokenCheckoutSessionRequest>;
export type CreateTokenCheckoutSessionResponse = z.infer<typeof createTokenCheckoutSessionResponse>;
export type TokenStripeStatusResponse = z.infer<typeof tokenStripeStatusResponse>;
export type TokenPurchaseStatus = z.infer<typeof tokenPurchaseStatusEnum>;
export type TokenPurchase = z.infer<typeof tokenPurchase>;
export type GetTokenPurchasesResponse = z.infer<typeof getTokenPurchasesResponse>;
export type FinalizeCheckoutResponse = z.infer<typeof finalizeCheckoutResponse>; export type FinalizeCheckoutResponse = z.infer<typeof finalizeCheckoutResponse>;

View file

@ -6,8 +6,7 @@ export const user = z.object({
is_active: z.boolean(), is_active: z.boolean(),
is_superuser: z.boolean(), is_superuser: z.boolean(),
is_verified: z.boolean(), is_verified: z.boolean(),
pages_limit: z.number(), credit_micros_balance: z.number(),
pages_used: z.number(),
display_name: z.string().nullish(), display_name: z.string().nullish(),
avatar_url: z.string().nullish(), avatar_url: z.string().nullish(),
}); });

View file

@ -22,7 +22,7 @@ const CATEGORY_TYPES: Record<NotificationCategory, string[]> = {
"connector_indexing", "connector_indexing",
"connector_deletion", "connector_deletion",
"document_processing", "document_processing",
"page_limit_exceeded", "insufficient_credits",
], ],
}; };

View file

@ -1,64 +1,50 @@
import { import {
type CreateCheckoutSessionRequest, type AutoReloadSettingsResponse,
type CreateCheckoutSessionResponse, autoReloadSettingsResponse,
type CreateTokenCheckoutSessionRequest, type CreateAutoReloadSetupSessionRequest,
type CreateTokenCheckoutSessionResponse, type CreateAutoReloadSetupSessionResponse,
createCheckoutSessionResponse, type CreateCreditCheckoutSessionRequest,
createTokenCheckoutSessionResponse, type CreateCreditCheckoutSessionResponse,
type CreditStripeStatusResponse,
createAutoReloadSetupSessionResponse,
createCreditCheckoutSessionResponse,
creditStripeStatusResponse,
type FinalizeCheckoutResponse, type FinalizeCheckoutResponse,
finalizeCheckoutResponse, finalizeCheckoutResponse,
type GetCreditPurchasesResponse,
type GetPagePurchasesResponse, type GetPagePurchasesResponse,
type GetTokenPurchasesResponse, getCreditPurchasesResponse,
getPagePurchasesResponse, getPagePurchasesResponse,
getTokenPurchasesResponse, type UpdateAutoReloadSettingsRequest,
type StripeStatusResponse,
stripeStatusResponse,
type TokenStripeStatusResponse,
tokenStripeStatusResponse,
} from "@/contracts/types/stripe.types"; } from "@/contracts/types/stripe.types";
import { baseApiService } from "./base-api.service"; import { baseApiService } from "./base-api.service";
class StripeApiService { class StripeApiService {
createCheckoutSession = async ( createCreditCheckoutSession = async (
request: CreateCheckoutSessionRequest request: CreateCreditCheckoutSessionRequest
): Promise<CreateCheckoutSessionResponse> => { ): Promise<CreateCreditCheckoutSessionResponse> => {
return baseApiService.post( return baseApiService.post(
"/api/v1/stripe/create-checkout-session", "/api/v1/stripe/create-credit-checkout-session",
createCheckoutSessionResponse, createCreditCheckoutSessionResponse,
{
body: request,
}
);
};
getPurchases = async (): Promise<GetPagePurchasesResponse> => {
return baseApiService.get("/api/v1/stripe/purchases", getPagePurchasesResponse);
};
getStatus = async (): Promise<StripeStatusResponse> => {
return baseApiService.get("/api/v1/stripe/status", stripeStatusResponse);
};
createTokenCheckoutSession = async (
request: CreateTokenCheckoutSessionRequest
): Promise<CreateTokenCheckoutSessionResponse> => {
return baseApiService.post(
"/api/v1/stripe/create-token-checkout-session",
createTokenCheckoutSessionResponse,
{ body: request } { body: request }
); );
}; };
getTokenStatus = async (): Promise<TokenStripeStatusResponse> => { getCreditStatus = async (): Promise<CreditStripeStatusResponse> => {
return baseApiService.get("/api/v1/stripe/token-status", tokenStripeStatusResponse); return baseApiService.get("/api/v1/stripe/credit-status", creditStripeStatusResponse);
}; };
getTokenPurchases = async (): Promise<GetTokenPurchasesResponse> => { getCreditPurchases = async (): Promise<GetCreditPurchasesResponse> => {
return baseApiService.get("/api/v1/stripe/token-purchases", getTokenPurchasesResponse); return baseApiService.get("/api/v1/stripe/credit-purchases", getCreditPurchasesResponse);
};
/** Legacy page-purchase history (read-only; page buying is removed). */
getPagePurchases = async (): Promise<GetPagePurchasesResponse> => {
return baseApiService.get("/api/v1/stripe/purchases", getPagePurchasesResponse);
}; };
/** /**
* Synchronously fulfil a checkout session from the success page. * Synchronously fulfil a credit checkout session from the success page.
* *
* Solves the race where the user lands on /purchase-success before * Solves the race where the user lands on /purchase-success before
* Stripe's checkout.session.completed webhook arrives. Idempotent * Stripe's checkout.session.completed webhook arrives. Idempotent
@ -70,6 +56,30 @@ class StripeApiService {
finalizeCheckoutResponse finalizeCheckoutResponse
); );
}; };
// --- Auto-reload --------------------------------------------------------
getAutoReloadSettings = async (): Promise<AutoReloadSettingsResponse> => {
return baseApiService.get("/api/v1/stripe/auto-reload", autoReloadSettingsResponse);
};
updateAutoReloadSettings = async (
request: UpdateAutoReloadSettingsRequest
): Promise<AutoReloadSettingsResponse> => {
return baseApiService.put("/api/v1/stripe/auto-reload", autoReloadSettingsResponse, {
body: request,
});
};
createAutoReloadSetupSession = async (
request: CreateAutoReloadSetupSessionRequest
): Promise<CreateAutoReloadSetupSessionResponse> => {
return baseApiService.post(
"/api/v1/stripe/auto-reload/setup",
createAutoReloadSetupSessionResponse,
{ body: request }
);
};
} }
export const stripeApiService = new StripeApiService(); export const stripeApiService = new StripeApiService();

View file

@ -569,10 +569,10 @@ export function trackIncentivePageViewed() {
safeCapture("incentive_page_viewed"); safeCapture("incentive_page_viewed");
} }
export function trackIncentiveTaskCompleted(taskType: string, pagesRewarded: number) { export function trackIncentiveTaskCompleted(taskType: string, creditMicrosRewarded: number) {
safeCapture("incentive_task_completed", { safeCapture("incentive_task_completed", {
task_type: taskType, task_type: taskType,
pages_rewarded: pagesRewarded, credit_micros_rewarded: creditMicrosRewarded,
}); });
} }

View file

@ -3,18 +3,16 @@ import { number, string, table } from "@rocicorp/zero";
/** /**
* Live-meter slice of the ``user`` table replicated through Zero. * Live-meter slice of the ``user`` table replicated through Zero.
* *
* ``premiumCreditMicrosLimit`` / ``premiumCreditMicrosUsed`` are stored * ``creditMicrosBalance`` is stored as integer micro-USD (1_000_000 == $1.00);
* as integer micro-USD (1_000_000 == $1.00). UI consumers divide by 1M * UI consumers divide by 1M when displaying and clamp at $0.00 (the balance can
* when displaying. Sensitive fields (email, hashed_password, oauth, etc.) * dip slightly negative when actual cost exceeds the pre-charge estimate).
* are intentionally omitted via the Postgres column-list publication so * Sensitive fields (email, hashed_password, oauth, etc.) are intentionally
* they never enter WAL replication. * omitted via the Postgres column-list publication so they never enter WAL
* replication.
*/ */
export const userTable = table("user") export const userTable = table("user")
.columns({ .columns({
id: string(), id: string(),
pagesLimit: number().from("pages_limit"), creditMicrosBalance: number().from("credit_micros_balance"),
pagesUsed: number().from("pages_used"),
premiumCreditMicrosLimit: number().from("premium_credit_micros_limit"),
premiumCreditMicrosUsed: number().from("premium_credit_micros_used"),
}) })
.primaryKey("id"); .primaryKey("id");