SurfSense/surfsense_backend/app/services/auto_reload_service.py
DESKTOP-RTLN3BA\$punk a7407502d3 feat(refactor): refactor payment system to implement unified credit wallet.
- Updated environment variables and - configurations for credit purchases via Stripe, replacing legacy page pack system.
- Introduced auto-reload feature for credit top-ups and modified database models to track credit transactions.
- Updated notification system to handle insufficient credits and auto-reload failures.
- Adjusted API routes and schemas to reflect changes in credit management.
2026-06-10 16:49:03 -07:00

99 lines
3.5 KiB
Python

"""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,
)