| Server IP : 3.147.158.171 / Your IP : 216.73.216.216 Web Server : Apache/2.4.67 (Amazon Linux) OpenSSL/3.5.5 System : Linux ip-172-31-2-178.us-east-2.compute.internal 6.1.172-216.329.amzn2023.x86_64 #1 SMP PREEMPT_DYNAMIC Wed May 20 06:31:34 UTC 2026 x86_64 User : ec2-user ( 1000) PHP Version : 8.4.21 Disable Function : NONE MySQL : OFF | cURL : ON | WGET : ON | Perl : ON | Python : OFF | Sudo : ON | Pkexec : OFF Directory : /tsai/repo/api/ |
Upload File : |
"""
Credit Reconciliation Sweep
Pushes each site's current metered spend to the store so the credit balance
stays accurate for *all* usage — including cheap, ungated operations (plain
chat, quizzes) that never pass through `credits_guard.ensure_allowed()`.
Why this exists
---------------
Enforcement gates only the expensive/automated operations (articles, image
studio, AMA research). Those call `credits_guard.check_credits()`, which reports
the live `used` level to the store and triggers auto-recharge / cap. A site that
only ever runs ungated chat would therefore never update its stored `used`, so
auto-recharge and the monthly cap would never fire for it. This sweep closes
that gap: it evaluates every site on a schedule, so ungated spend is reconciled
and billed just like gated spend.
For each site with a credit account it runs the same `check_credits()` path the
gates use (compute `used` since the period start → POST /scike-credits/check →
store updates `used`, recharges if allowed, or caps). Sites with no account, or
when the store is unreachable, fail open and cost one cheap status call.
Usage:
python reconcile_credits.py [--slug <slug>] [--verbose]
--slug <slug> Reconcile only this site (default: every configured site).
--verbose Log a line per site (default: only summary + actions).
Intended to run on a schedule (e.g. hourly systemd timer), alongside the
article/comment crons.
"""
import argparse
import asyncio
import logging
import time
# IMPORTANT: Import cron_common FIRST to load .env before any app imports.
import cron_common
from app.services import credits_client, credits_guard
from app.services.site_config_service import SiteConfigService
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Reconcile metered credit usage to the store.")
parser.add_argument('--slug', help="Reconcile only this site slug.")
parser.add_argument('--verbose', action='store_true', help="Log a line per site.")
return parser.parse_args()
async def reconcile() -> int:
"""Reconcile every (or one) site's usage to the store. Returns exit code."""
args = parse_args()
if not credits_client.is_configured():
logging.info("Credits bridge not configured (STORE_CREDITS_URL / SCIKE_CREDITS_SECRET); nothing to do.")
return 0
site_config_service = SiteConfigService()
sites = site_config_service.get_all_sites()
if args.slug:
sites = [s for s in sites if s.slug == args.slug]
if not sites:
logging.error(f"No configured site with slug '{args.slug}'.")
return 1
# Reconcile distinct slugs only (dev aliases can share a slug).
seen: set[str] = set()
checked = recharged = capped = 0
for site in sites:
slug = site.slug
if not slug or slug in seen:
continue
seen.add(slug)
# A fresh process has empty caches, so this always does real work:
# compute used → ask the store → it updates used / recharges / caps.
credits_guard.invalidate(slug)
try:
verdict = await credits_guard.check_credits(slug, operation='reconcile')
except Exception as e:
logging.warning(f"Reconcile failed for '{slug}': {e}")
continue
# No account or store unavailable → fail-open sentinel; skip quietly.
if verdict.get('status') in (None, 'unknown') and verdict.get('reason') == 'unavailable':
if args.verbose:
logging.info(f" {slug}: no account / unavailable — skipped")
continue
checked += 1
if verdict.get('recharged'):
recharged += 1
logging.info(
f" {slug}: auto-recharged (remaining now {verdict.get('remaining')})"
)
elif not verdict.get('allowed', True):
capped += 1
logging.info(
f" {slug}: capped/paused (reason={verdict.get('reason')}, remaining={verdict.get('remaining')})"
)
elif args.verbose:
logging.info(
f" {slug}: ok (remaining {verdict.get('remaining')})"
)
logging.info(
f"Reconcile complete: {checked} account(s) checked, {recharged} recharged, {capped} capped."
)
return 0
if __name__ == '__main__':
cron_common.setup_logging('reconcile_credits.log')
start = time.time()
try:
code = asyncio.run(reconcile())
except Exception as e:
logging.error(f"Credit reconciliation failed: {e}", exc_info=True)
raise SystemExit(1)
logging.info(f"Script completed ({time.time() - start:.2f}s total)")
raise SystemExit(code)