Files
2026-09-08 11:00:05 +05:30

136 lines
4.7 KiB
Python

"""
Which tenants still need a real plan?
The D1 backfill put **every** tenant on `custom` and carried their existing
limits over as overrides. That was the right call — inferring a commercial fact
("their quota looks like Starter, so they must be on Starter") is how somebody
ends up on the wrong plan and finds out at renewal.
But it leaves work undone with nothing to show for it: a safe migration that
quietly parks everyone in a holding pen. This is the report that says so.
APP_ENV=rework python scripts/plan_review.py
Read-only. It prints, suggests, and changes nothing — moving a tenant onto a
plan is a commercial decision and belongs in the admin UI, made by somebody who
knows what was actually sold.
"""
import argparse
import os
import sys
from pathlib import Path
os.environ.setdefault("APP_ENV", "rework")
os.environ.setdefault("PYTHONIOENCODING", "utf-8")
sys.path.insert(0, str(Path(__file__).parent.parent))
GB = 1024 ** 3
def human(key: str, value) -> str:
if value is None:
return "-"
if value == -1:
return "unlimited"
if key.endswith("_bytes"):
return f"{value / GB:.0f} GB" if value >= GB else f"{value / 1024 ** 2:.0f} MB"
return f"{value:,}"
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--database-url", help="Override the URL from settings.")
args = parser.parse_args()
from sqlalchemy import create_engine, text
url = args.database_url
if not url:
from app.core.settings import settings
url = settings.DATABASE_URL
engine = create_engine(url)
try:
with engine.connect() as conn:
rows = conn.execute(
text(
"""
SELECT t.id, t.name, p.code, s.status,
(SELECT count(*) FROM tenant_limit_overrides o
WHERE o.tenant_id = t.id) AS override_count
FROM tenants t
LEFT JOIN tenant_subscriptions s ON s.tenant_id = t.id
LEFT JOIN plans p ON p.id = s.plan_id
WHERE t.is_deleted = false
ORDER BY p.code NULLS FIRST, t.name
"""
)
).all()
catalogue = {
code: dict(
conn.execute(
text(
"SELECT l.key, l.value FROM plan_limits l "
" JOIN plans p ON p.id = l.plan_id WHERE p.code = :c"
),
{"c": code},
).all()
)
for (code,) in conn.execute(
text("SELECT code FROM plans WHERE is_public ORDER BY sort_order")
)
}
print(f"{len(rows)} live tenant(s)\n")
print(f"{'tenant':<34}{'plan':<14}{'status':<12}{'overrides':>10}")
print("-" * 72)
needs_review = []
for tenant_id, name, code, status, overrides in rows:
print(
f"{(name or str(tenant_id))[:33]:<34}"
f"{(code or 'NONE'):<14}{(status or '-'):<12}{overrides:>10}"
)
if code in (None, "custom"):
needs_review.append((tenant_id, name, overrides))
if not needs_review:
print("\nEvery tenant is on a named plan.")
return 0
print(
f"\n{len(needs_review)} tenant(s) are on `custom` or have no "
"subscription at all."
)
print(
"\nThat is what the D1 backfill does on purpose: it carries the\n"
"existing limits over as overrides and refuses to guess which\n"
"plan somebody bought. Their limits are unchanged and correct —\n"
"what is missing is the commercial fact.\n"
)
print("For reference, the public catalogue:\n")
keys = sorted({k for limits in catalogue.values() for k in limits})
print(f" {'plan':<16}" + "".join(f"{k:>20}" for k in keys))
for code, limits in catalogue.items():
print(
f" {code:<16}"
+ "".join(f"{human(k, limits.get(k)):>20}" for k in keys)
)
print(
"\nMove a tenant with PUT /api/billing/tenants/{id}/subscription,\n"
"then delete the carried-over overrides so the plan actually\n"
"applies — while an override exists, the plan is only a label."
)
return 1
finally:
engine.dispose()
if __name__ == "__main__":
raise SystemExit(main())